diff --git a/app/build.gradle b/app/build.gradle index 4426dbeeb86..65f4b9c605f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -185,7 +185,7 @@ configurations.configureEach { } dependencies { - + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.10.0' kapt "org.jetbrains.kotlin:kotlin-metadata-jvm:${kotlinVersion}" implementation "androidx.room:room-testing-android:${roomVersion}" implementation 'androidx.compose.foundation:foundation-layout:1.10.3' diff --git a/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/24.json b/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/24.json new file mode 100644 index 00000000000..3a9e74d9997 --- /dev/null +++ b/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/24.json @@ -0,0 +1,810 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "0268ae43ccc84783e56ad472a77b37ba", + "entities": [ + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `username` TEXT, `baseUrl` TEXT, `token` TEXT, `displayName` TEXT, `pushConfigurationState` TEXT, `capabilities` TEXT, `serverVersion` TEXT DEFAULT '', `clientCertificate` TEXT, `externalSignalingServer` TEXT, `current` INTEGER NOT NULL, `scheduledForDeletion` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "pushConfigurationState", + "columnName": "pushConfigurationState", + "affinity": "TEXT" + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT" + }, + { + "fieldPath": "serverVersion", + "columnName": "serverVersion", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "clientCertificate", + "columnName": "clientCertificate", + "affinity": "TEXT" + }, + { + "fieldPath": "externalSignalingServer", + "columnName": "externalSignalingServer", + "affinity": "TEXT" + }, + { + "fieldPath": "current", + "columnName": "current", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledForDeletion", + "columnName": "scheduledForDeletion", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ArbitraryStorage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountIdentifier` INTEGER NOT NULL, `key` TEXT NOT NULL, `object` TEXT, `value` TEXT, PRIMARY KEY(`accountIdentifier`, `key`))", + "fields": [ + { + "fieldPath": "accountIdentifier", + "columnName": "accountIdentifier", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "storageObject", + "columnName": "object", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountIdentifier", + "key" + ] + } + }, + { + "tableName": "Conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `displayName` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `avatarVersion` TEXT NOT NULL, `callFlag` INTEGER NOT NULL, `callRecording` INTEGER NOT NULL, `callStartTime` INTEGER NOT NULL, `canDeleteConversation` INTEGER NOT NULL, `canLeaveConversation` INTEGER NOT NULL, `canStartCall` INTEGER NOT NULL, `description` TEXT NOT NULL, `hasCall` INTEGER NOT NULL, `hasPassword` INTEGER NOT NULL, `isCustomAvatar` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `lastActivity` INTEGER NOT NULL, `lastCommonReadMessage` INTEGER NOT NULL, `lastMessage` TEXT, `lastPing` INTEGER NOT NULL, `lastReadMessage` INTEGER NOT NULL, `lobbyState` TEXT NOT NULL, `lobbyTimer` INTEGER NOT NULL, `messageExpiration` INTEGER NOT NULL, `name` TEXT NOT NULL, `notificationCalls` INTEGER NOT NULL, `notificationLevel` TEXT NOT NULL, `objectType` TEXT NOT NULL, `objectId` TEXT NOT NULL, `participantType` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `readOnly` TEXT NOT NULL, `recordingConsent` INTEGER NOT NULL, `remoteServer` TEXT, `remoteToken` TEXT, `sessionId` TEXT NOT NULL, `status` TEXT, `statusClearAt` INTEGER, `statusIcon` TEXT, `statusMessage` TEXT, `type` TEXT NOT NULL, `unreadMention` INTEGER NOT NULL, `unreadMentionDirect` INTEGER NOT NULL, `unreadMessages` INTEGER NOT NULL, `hasArchived` INTEGER NOT NULL, `hasSensitive` INTEGER NOT NULL, `hasImportant` INTEGER NOT NULL, `hiddenPinnedId` INTEGER, `lastPinnedId` INTEGER, `messageDraft` TEXT, PRIMARY KEY(`internalId`), FOREIGN KEY(`accountId`) REFERENCES `User`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "internalId", + "columnName": "internalId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorId", + "columnName": "actorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorType", + "columnName": "actorType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatarVersion", + "columnName": "avatarVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "callFlag", + "columnName": "callFlag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callRecording", + "columnName": "callRecording", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callStartTime", + "columnName": "callStartTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDeleteConversation", + "columnName": "canDeleteConversation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canLeaveConversation", + "columnName": "canLeaveConversation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canStartCall", + "columnName": "canStartCall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasCall", + "columnName": "hasCall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPassword", + "columnName": "hasPassword", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasCustomAvatar", + "columnName": "isCustomAvatar", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCommonReadMessage", + "columnName": "lastCommonReadMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessage", + "columnName": "lastMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "lastPing", + "columnName": "lastPing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessage", + "columnName": "lastReadMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lobbyState", + "columnName": "lobbyState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lobbyTimer", + "columnName": "lobbyTimer", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageExpiration", + "columnName": "messageExpiration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationCalls", + "columnName": "notificationCalls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLevel", + "columnName": "notificationLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectType", + "columnName": "objectType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantType", + "columnName": "participantType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationReadOnlyState", + "columnName": "readOnly", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordingConsentRequired", + "columnName": "recordingConsent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteServer", + "columnName": "remoteServer", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteToken", + "columnName": "remoteToken", + "affinity": "TEXT" + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "statusClearAt", + "columnName": "statusClearAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "statusIcon", + "columnName": "statusIcon", + "affinity": "TEXT" + }, + { + "fieldPath": "statusMessage", + "columnName": "statusMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unreadMention", + "columnName": "unreadMention", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMentionDirect", + "columnName": "unreadMentionDirect", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMessages", + "columnName": "unreadMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasArchived", + "columnName": "hasArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSensitive", + "columnName": "hasSensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasImportant", + "columnName": "hasImportant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hiddenPinnedId", + "columnName": "hiddenPinnedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastPinnedId", + "columnName": "lastPinnedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "messageDraft", + "columnName": "messageDraft", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "internalId" + ] + }, + "indices": [ + { + "name": "index_Conversations_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Conversations_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `id` INTEGER NOT NULL, `internalConversationId` TEXT NOT NULL, `threadId` INTEGER, `isThread` INTEGER NOT NULL, `actorDisplayName` TEXT NOT NULL, `message` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `isReplyable` INTEGER NOT NULL, `isTemporary` INTEGER NOT NULL, `lastEditActorDisplayName` TEXT, `lastEditActorId` TEXT, `lastEditActorType` TEXT, `lastEditTimestamp` INTEGER, `markdown` INTEGER, `messageParameters` TEXT, `messageType` TEXT NOT NULL, `parent` INTEGER, `reactions` TEXT, `reactionsSelf` TEXT, `referenceId` TEXT, `sendStatus` TEXT, `silent` INTEGER NOT NULL, `systemMessage` TEXT NOT NULL, `threadTitle` TEXT, `threadReplies` INTEGER, `timestamp` INTEGER NOT NULL, `pinnedActorType` TEXT, `pinnedActorId` TEXT, `pinnedActorDisplayName` TEXT, `pinnedAt` INTEGER, `pinnedUntil` INTEGER, `sendAt` INTEGER, PRIMARY KEY(`internalId`), FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "internalId", + "columnName": "internalId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalConversationId", + "columnName": "internalConversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isThread", + "columnName": "isThread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actorDisplayName", + "columnName": "actorDisplayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorId", + "columnName": "actorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorType", + "columnName": "actorType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTimestamp", + "columnName": "expirationTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replyable", + "columnName": "isReplyable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTemporary", + "columnName": "isTemporary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEditActorDisplayName", + "columnName": "lastEditActorDisplayName", + "affinity": "TEXT" + }, + { + "fieldPath": "lastEditActorId", + "columnName": "lastEditActorId", + "affinity": "TEXT" + }, + { + "fieldPath": "lastEditActorType", + "columnName": "lastEditActorType", + "affinity": "TEXT" + }, + { + "fieldPath": "lastEditTimestamp", + "columnName": "lastEditTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "renderMarkdown", + "columnName": "markdown", + "affinity": "INTEGER" + }, + { + "fieldPath": "messageParameters", + "columnName": "messageParameters", + "affinity": "TEXT" + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentMessageId", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactions", + "columnName": "reactions", + "affinity": "TEXT" + }, + { + "fieldPath": "reactionsSelf", + "columnName": "reactionsSelf", + "affinity": "TEXT" + }, + { + "fieldPath": "referenceId", + "columnName": "referenceId", + "affinity": "TEXT" + }, + { + "fieldPath": "sendStatus", + "columnName": "sendStatus", + "affinity": "TEXT" + }, + { + "fieldPath": "silent", + "columnName": "silent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemMessageType", + "columnName": "systemMessage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threadTitle", + "columnName": "threadTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "threadReplies", + "columnName": "threadReplies", + "affinity": "INTEGER" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinnedActorType", + "columnName": "pinnedActorType", + "affinity": "TEXT" + }, + { + "fieldPath": "pinnedActorId", + "columnName": "pinnedActorId", + "affinity": "TEXT" + }, + { + "fieldPath": "pinnedActorDisplayName", + "columnName": "pinnedActorDisplayName", + "affinity": "TEXT" + }, + { + "fieldPath": "pinnedAt", + "columnName": "pinnedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "pinnedUntil", + "columnName": "pinnedUntil", + "affinity": "INTEGER" + }, + { + "fieldPath": "sendAt", + "columnName": "sendAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "internalId" + ] + }, + "indices": [ + { + "name": "index_ChatMessages_internalId", + "unique": true, + "columnNames": [ + "internalId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ChatMessages_internalId` ON `${TABLE_NAME}` (`internalId`)" + }, + { + "name": "index_ChatMessages_internalConversationId", + "unique": false, + "columnNames": [ + "internalConversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessages_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)" + }, + { + "name": "index_ChatMessages_referenceId", + "unique": true, + "columnNames": [ + "referenceId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ChatMessages_referenceId` ON `${TABLE_NAME}` (`referenceId`)" + } + ], + "foreignKeys": [ + { + "table": "Conversations", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "internalConversationId" + ], + "referencedColumns": [ + "internalId" + ] + } + ] + }, + { + "tableName": "ChatBlocks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalConversationId` TEXT NOT NULL, `accountId` INTEGER, `token` TEXT, `threadId` INTEGER, `oldestMessageId` INTEGER NOT NULL, `newestMessageId` INTEGER NOT NULL, `hasHistory` INTEGER NOT NULL, FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalConversationId", + "columnName": "internalConversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "INTEGER" + }, + { + "fieldPath": "oldestMessageId", + "columnName": "oldestMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "newestMessageId", + "columnName": "newestMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasHistory", + "columnName": "hasHistory", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ChatBlocks_internalConversationId", + "unique": false, + "columnNames": [ + "internalConversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatBlocks_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)" + } + ], + "foreignKeys": [ + { + "table": "Conversations", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "internalConversationId" + ], + "referencedColumns": [ + "internalId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0268ae43ccc84783e56ad472a77b37ba')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt b/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt index 6bcce26f8ac..ef2027497c8 100644 --- a/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt +++ b/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt @@ -123,16 +123,16 @@ class ChatMessagesDaoTest { ) ) - chatMessagesDao.getMessagesForConversation(conversation1.internalId).first().forEach { + chatMessagesDao.getMessagesForConversation(conversation1.internalId, null).first().forEach { Log.d(tag, "- next Message for conversation1 (account1)-") Log.d(tag, "id (PK): " + it.id) Log.d(tag, "message: " + it.message) } - val chatMessagesConv1 = chatMessagesDao.getMessagesForConversation(conversation1.internalId) + val chatMessagesConv1 = chatMessagesDao.getMessagesForConversation(conversation1.internalId, null) assertEquals(5, chatMessagesConv1.first().size) - val chatMessagesConv2 = chatMessagesDao.getMessagesForConversation(conversation2.internalId) + val chatMessagesConv2 = chatMessagesDao.getMessagesForConversation(conversation2.internalId, null) assertEquals(1, chatMessagesConv2.first().size) assertEquals("some", chatMessagesConv1.first()[1].message) diff --git a/app/src/androidTest/java/com/nextcloud/talk/data/database/migrations/MigrationsTest.kt b/app/src/androidTest/java/com/nextcloud/talk/data/database/migrations/MigrationsTest.kt index 978bab2e5f4..9b9f443d3a6 100644 --- a/app/src/androidTest/java/com/nextcloud/talk/data/database/migrations/MigrationsTest.kt +++ b/app/src/androidTest/java/com/nextcloud/talk/data/database/migrations/MigrationsTest.kt @@ -7,12 +7,15 @@ package com.nextcloud.talk.data.database.migrations -import androidx.room.Room +import android.database.sqlite.SQLiteConstraintException import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.nextcloud.talk.data.source.local.Migrations import com.nextcloud.talk.data.source.local.TalkDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -20,10 +23,9 @@ import java.io.IOException @RunWith(AndroidJUnit4::class) class MigrationsTest { + companion object { private const val TEST_DB = "migration-test" - private const val INIT_VERSION = 10 // last version before update to offline first - private val TAG = MigrationsTest::class.java.simpleName } @get:Rule @@ -32,21 +34,96 @@ class MigrationsTest { TalkDatabase::class.java ) - @Test - @Throws(IOException::class) - @Suppress("SpreadOperator") - fun migrateAll() { - helper.createDatabase(TEST_DB, INIT_VERSION).apply { - close() - } - - Room.databaseBuilder( - InstrumentationRegistry.getInstrumentation().targetContext, - TalkDatabase::class.java, - TEST_DB - ).addMigrations(*TalkDatabase.MIGRATIONS).build().apply { - openHelper.writableDatabase.close() - } + private fun insertMessage( + db: SupportSQLiteDatabase, + internalId: String, + referenceId: String?, + isTemporary: Int, + timestamp: Long + ) { + db.execSQL( + """ + INSERT INTO ChatMessages ( + internalId, + accountId, + token, + id, + internalConversationId, + threadId, + isThread, + actorDisplayName, + message, + actorId, + actorType, + deleted, + expirationTimestamp, + isReplyable, + isTemporary, + lastEditActorDisplayName, + lastEditActorId, + lastEditActorType, + lastEditTimestamp, + markdown, + messageParameters, + messageType, + parent, + reactions, + reactionsSelf, + referenceId, + sendStatus, + silent, + systemMessage, + threadTitle, + threadReplies, + timestamp, + pinnedActorType, + pinnedActorId, + pinnedActorDisplayName, + pinnedAt, + pinnedUntil, + sendAt + ) VALUES ( + '$internalId', + 1, + 'token', + 1, + 'conv', + NULL, + 0, + 'User', + 'Hello', + 'actor1', + 'USER', + 0, + 0, + 0, + $isTemporary, + NULL, + NULL, + NULL, + 0, + 0, + NULL, + 'comment', + NULL, + NULL, + NULL, + ${if (referenceId != null) "'$referenceId'" else "NULL"}, + NULL, + 0, + 0, + NULL, + 0, + $timestamp, + NULL, + NULL, + NULL, + NULL, + NULL, + 0 + ) + """ + ) } @Test @@ -111,4 +188,124 @@ class MigrationsTest { } helper.runMigrationsAndValidate(TEST_DB, 19, true, Migrations.MIGRATION_17_19) } + + @Test + fun migrate23To24_prefersNonTemporary() { + var db = helper.createDatabase(TEST_DB, 23) + + insertMessage(db, "1", "ref1", 1, 1000) + insertMessage(db, "2", "ref1", 0, 2000) + + db.close() + + db = helper.runMigrationsAndValidate( + TEST_DB, + 24, + true, + Migrations.MIGRATION_23_24 + ) + + val cursor = db.query( + """ + SELECT internalId, isTemporary, timestamp + FROM ChatMessages + WHERE referenceId = 'ref1' + """ + ) + + assertEquals(1, cursor.count) + assertTrue(cursor.moveToFirst()) + + val internalId = cursor.getString(0) + val isTemporary = cursor.getInt(1) + val timestamp = cursor.getLong(2) + + cursor.close() + + assertEquals("2", internalId) + assertEquals(0, isTemporary) + assertEquals(2000L, timestamp) + } + + @Test + fun migrate23To24_keepsNewestWhenAllTemporary() { + var db = helper.createDatabase(TEST_DB, 23) + + insertMessage(db, "1", "ref2", 1, 1000) + insertMessage(db, "2", "ref2", 1, 2000) + + db.close() + + db = helper.runMigrationsAndValidate( + TEST_DB, + 24, + true, + Migrations.MIGRATION_23_24 + ) + + val cursor = db.query( + """ + SELECT internalId, timestamp + FROM ChatMessages + WHERE referenceId = 'ref2' + """ + ) + + assertEquals(1, cursor.count) + assertTrue(cursor.moveToFirst()) + + val internalId = cursor.getString(0) + val timestamp = cursor.getLong(1) + + cursor.close() + + assertEquals("2", internalId) + assertEquals(2000L, timestamp) + } + + @Test + fun migrate23To24_allowsMultipleNullReferenceIds() { + var db = helper.createDatabase(TEST_DB, 23) + + insertMessage(db, "1", null, 0, 1000) + insertMessage(db, "2", null, 0, 2000) + + db.close() + + db = helper.runMigrationsAndValidate( + TEST_DB, + 24, + true, + Migrations.MIGRATION_23_24 + ) + + val cursor = db.query( + """ + SELECT COUNT(*) FROM ChatMessages WHERE referenceId IS NULL + """ + ) + + assertTrue(cursor.moveToFirst()) + val count = cursor.getInt(0) + + cursor.close() + + assertEquals(2, count) + } + + @Test(expected = SQLiteConstraintException::class) + fun migrate23To24_enforcesUniqueIndex() { + var db = helper.createDatabase(TEST_DB, 23) + db.close() + + db = helper.runMigrationsAndValidate( + TEST_DB, + 24, + true, + Migrations.MIGRATION_23_24 + ) + + insertMessage(db, "1", "dup", 0, 1000) + insertMessage(db, "2", "dup", 0, 2000) + } } diff --git a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt index c7e5b8f437f..cf8b9a83e6f 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt @@ -44,6 +44,7 @@ import com.nextcloud.talk.utils.adjustUIForAPILevel35 import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.database.user.CurrentUserProvider import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import com.nextcloud.talk.utils.message.MessageUtils import com.nextcloud.talk.utils.preferences.AppPreferences import com.nextcloud.talk.utils.ssl.TrustManager import org.greenrobot.eventbus.EventBus @@ -72,6 +73,9 @@ open class BaseActivity : AppCompatActivity() { @Inject lateinit var viewThemeUtils: ViewThemeUtils + @Inject + lateinit var messageUtils: MessageUtils + @Inject lateinit var context: Context diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 753c72b08ca..4b89682806d 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -284,7 +284,10 @@ class CallActivity : CallBaseActivity() { private var isBreakoutRoom = false private val localParticipantMessageListener = LocalParticipantMessageListener { token -> switchToRoomToken = token - hangup(true, false) + hangup( + shutDownView = true, + endCallForAll = false + ) } private val offerMessageListener = OfferMessageListener { sessionId, roomType, sdp, nick -> getOrCreatePeerConnectionWrapperForSessionIdAndType( @@ -1900,7 +1903,7 @@ class CallActivity : CallBaseActivity() { when (messageType) { "usersInRoom" -> - internalSignalingMessageReceiver.process(signaling.messageWrapper as List?>?) + internalSignalingMessageReceiver.process(signaling.messageWrapper as List>) "message" -> { val ncSignalingMessage = LoganSquare.parse( @@ -2716,11 +2719,11 @@ class CallActivity : CallBaseActivity() { * All listeners are called in the main thread. */ private class InternalSignalingMessageReceiver : SignalingMessageReceiver() { - fun process(users: List?>?) { + fun process(users: List>) { processUsersInRoom(users) } - fun process(message: NCSignalingMessage?) { + fun process(message: NCSignalingMessage) { processSignalingMessage(message) } } diff --git a/app/src/main/java/com/nextcloud/talk/activities/ParticipantHandler.kt b/app/src/main/java/com/nextcloud/talk/activities/ParticipantHandler.kt index 62bd61e781c..09d681bd306 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/ParticipantHandler.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/ParticipantHandler.kt @@ -139,7 +139,7 @@ class ParticipantHandler( _uiState.update { it.copy(raisedHand = state) } } - override fun onReaction(reaction: String?) { + override fun onReaction(reaction: String) { Log.d(TAG, "onReaction") } diff --git a/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt b/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt index a21a8d7b2e8..ab5ed2e23a5 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt @@ -28,7 +28,7 @@ import com.nextcloud.talk.adapters.items.ConversationItem.ConversationItemViewHo import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.ChatMessage.MessageType -import com.nextcloud.talk.data.database.mappers.asModel +import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.RvItemConversationWithLastMessageBinding import com.nextcloud.talk.extensions.loadConversationAvatar @@ -60,7 +60,7 @@ class ConversationItem( ISectionable, IFilterable { private var header: GenericTextHeaderItem? = null - private val chatMessage = model.lastMessage?.asModel() + private val chatMessage = model.lastMessage?.toDomainModel() var mHolder: ConversationItemViewHolder? = null constructor( diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt index b1722de2d42..99442632ff7 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt @@ -10,6 +10,7 @@ package com.nextcloud.talk.adapters.messages import android.content.Context +import android.text.SpannableStringBuilder import android.util.Log import android.util.TypedValue import android.view.View @@ -150,10 +151,10 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) : binding.messageAuthor.visibility = View.GONE } binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) - binding.messageText.text = processedMessageText + // binding.messageText.text = processedMessageText // just for debugging: - // binding.messageText.text = - // SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")") + binding.messageText.text = + SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")") } else { binding.checkboxContainer.visibility = View.VISIBLE binding.messageText.visibility = View.GONE diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt index 1f7afdc4496..afec599aaf5 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt @@ -10,6 +10,7 @@ package com.nextcloud.talk.adapters.messages import android.content.Context +import android.text.SpannableStringBuilder import android.util.Log import android.util.TypedValue import android.view.View @@ -163,10 +164,10 @@ class OutcomingTextMessageViewHolder(itemView: View) : binding.messageTime.layoutParams = layoutParams viewThemeUtils.platform.colorTextView(binding.messageText, ColorRole.ON_SURFACE_VARIANT) - binding.messageText.text = processedMessageText + // binding.messageText.text = processedMessageText // just for debugging: - // binding.messageText.text = - // SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")") + binding.messageText.text = + SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")") } else { binding.messageText.visibility = View.GONE binding.checkboxContainer.visibility = View.VISIBLE diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt index fda68f9f824..14e52008ab6 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt @@ -112,36 +112,37 @@ abstract class PreviewMessageViewHolder(itemView: View?, payload: Any?) : clickView = image messageText.visibility = View.VISIBLE if (message.getCalculateMessageType() === ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE) { - val chatActivity = commonMessageInterface as ChatActivity - fileViewerUtils = FileViewerUtils(chatActivity, message.activeUser!!) - val fileName = message.selectedIndividualHashMap!![KEY_NAME] - - messageText.text = fileName - - if (message.activeUser != null && - message.activeUser!!.username != null && - message.activeUser!!.baseUrl != null - ) { - clickView!!.setOnClickListener { v: View? -> - fileViewerUtils!!.openFile( - message, - ProgressUi(progressBar, messageText, image) - ) - } - clickView!!.setOnLongClickListener { - previewMessageInterface!!.onPreviewMessageLongClick(message) - true + message.activeUser?.let { + val chatActivity = commonMessageInterface as ChatActivity + fileViewerUtils = FileViewerUtils(chatActivity, it) + val fileName = message.selectedIndividualHashMap!![KEY_NAME] + messageText.text = fileName + if ( + it.username != null && + it.baseUrl != null + ) { + clickView!!.setOnClickListener { v: View? -> + fileViewerUtils!!.openFile( + message, + ProgressUi(progressBar, messageText, image) + ) + } + clickView!!.setOnLongClickListener { + previewMessageInterface!!.onPreviewMessageLongClick(message) + true + } } - } else { + + fileViewerUtils?.resumeToUpdateViewsByProgress( + message.selectedIndividualHashMap!![KEY_NAME]!!, + message.selectedIndividualHashMap!![KEY_ID]!!, + message.selectedIndividualHashMap!![KEY_MIMETYPE], + message.openWhenDownloaded, + ProgressUi(progressBar, messageText, image) + ) + } ?: { Log.e(TAG, "failed to set click listener because activeUser, username or baseUrl were null") } - fileViewerUtils!!.resumeToUpdateViewsByProgress( - message.selectedIndividualHashMap!![KEY_NAME]!!, - message.selectedIndividualHashMap!![KEY_ID]!!, - message.selectedIndividualHashMap!![KEY_MIMETYPE], - message.openWhenDownloaded, - ProgressUi(progressBar, messageText, image) - ) } else if (message.getCalculateMessageType() === ChatMessage.MessageType.SINGLE_LINK_GIPHY_MESSAGE) { messageText.text = "GIPHY" DisplayUtils.setClickableString("GIPHY", "https://giphy.com", messageText) diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApi.java b/app/src/main/java/com/nextcloud/talk/api/NcApi.java index 972c0b1f738..b8d66819616 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -325,18 +325,6 @@ Observable> setPassword2(@Header("Authorization") Strin Observable getRoomCapabilities(@Header("Authorization") String authorization, @Url String url); - /* - QueryMap items are as follows: - - "lookIntoFuture": int (0 or 1), - - "limit" : int, range 100-200, - - "timeout": used with look into future, 30 default, 60 at most - - "lastKnownMessageId", int, use one from X-Chat-Last-Given - */ - @GET - Observable> pullChatMessages(@Header("Authorization") String authorization, - @Url String url, - @QueryMap Map fields); - /* Fieldmap items are as follows: - "message": , diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt index c01d7e3a025..98142e21b20 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -27,6 +27,7 @@ import com.nextcloud.talk.models.json.threads.ThreadsOverall import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall import okhttp3.MultipartBody import okhttp3.RequestBody +import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.Field @@ -367,4 +368,11 @@ interface NcApiCoroutines { @GET suspend fun getScheduledMessage(@Header("Authorization") authorization: String, @Url url: String): ChatOverall + + @GET + suspend fun pullChatMessages( + @Header("Authorization") authorization: String, + @Url url: String, + @QueryMap fields: Map + ): Response } diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index ef978b9b843..322a51a0b7c 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -61,8 +61,10 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ContextThemeWrapper import androidx.cardview.widget.CardView import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.coordinatorlayout.widget.CoordinatorLayout @@ -81,8 +83,11 @@ import androidx.core.view.WindowInsetsCompat import androidx.emoji2.text.EmojiCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.commit +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -141,6 +146,7 @@ import com.nextcloud.talk.contextchat.ContextChatViewModel import com.nextcloud.talk.conversationinfo.ConversationInfoActivity import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityChatBinding @@ -156,6 +162,7 @@ import com.nextcloud.talk.messagesearch.MessageSearchActivity import com.nextcloud.talk.models.ExternalSignalingServer import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.capabilities.SpreedCapability +import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.models.json.chat.ReadStatus import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.participants.Participant @@ -168,11 +175,11 @@ import com.nextcloud.talk.signaling.SignalingMessageReceiver import com.nextcloud.talk.signaling.SignalingMessageSender import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity import com.nextcloud.talk.translate.ui.TranslateActivity -import com.nextcloud.talk.ui.PinnedMessageView import com.nextcloud.talk.ui.PlaybackSpeed import com.nextcloud.talk.ui.PlaybackSpeedControl import com.nextcloud.talk.ui.StatusDrawable import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet +import com.nextcloud.talk.ui.chat.GetNewChatView import com.nextcloud.talk.ui.dialog.DateTimeCompose import com.nextcloud.talk.ui.dialog.FileAttachmentPreviewFragment import com.nextcloud.talk.ui.dialog.GetPinnedOptionsDialog @@ -182,6 +189,8 @@ import com.nextcloud.talk.ui.dialog.ShowReactionsDialog import com.nextcloud.talk.ui.dialog.TempMessageActionsDialog import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback +import com.nextcloud.talk.ui.theme.LocalMessageUtils +import com.nextcloud.talk.ui.theme.LocalViewThemeUtils import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.AudioUtils import com.nextcloud.talk.utils.CapabilitiesUtil @@ -232,11 +241,8 @@ import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -293,7 +299,19 @@ class ChatActivity : @Inject lateinit var networkMonitor: NetworkMonitor - lateinit var chatViewModel: ChatViewModel + @Inject + lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory + + var useJetpackCompose = true + + val chatViewModel: ChatViewModel by viewModels { + ViewModelFactoryWithParams(ChatViewModel::class.java) { + chatViewModelFactory.create( + roomToken, + conversationThreadId + ) + } + } lateinit var conversationInfoViewModel: ConversationInfoViewModel lateinit var contextChatViewModel: ContextChatViewModel @@ -356,7 +374,12 @@ class ChatActivity : messageId = messageId!!, title = currentConversation!!.displayName ) - ContextChatView(context, contextChatViewModel) + ContextChatView( + user = conversationUser, + context = context, + viewThemeUtils = viewThemeUtils, + contextViewModel = contextChatViewModel + ) } } Log.d(TAG, "Should open something else") @@ -376,8 +399,20 @@ class ChatActivity : val disposables = DisposableSet() var sessionIdAfterRoomJoined: String? = null - lateinit var roomToken: String - var conversationThreadId: Long? = null + + val roomToken: String by lazy { + intent.getStringExtra(KEY_ROOM_TOKEN) + ?: error("roomToken missing") + } + + val conversationThreadId: Long? by lazy { + if (intent.hasExtra(KEY_THREAD_ID)) { + intent.getLongExtra(KEY_THREAD_ID, 0L) + } else { + null + } + } + var openedViaNotification: Boolean = false var conversationThreadInfo: ThreadInfo? = null lateinit var conversationUser: User @@ -437,15 +472,15 @@ class ChatActivity : var callStarted = false - private val localParticipantMessageListener = object : SignalingMessageReceiver.LocalParticipantMessageListener { - override fun onSwitchTo(token: String?) { - if (token != null) { - if (CallActivity.active) { - Log.d(TAG, "CallActivity is running. Ignore to switch chat in ChatActivity...") - } else { - switchToRoom(token, false, false) - } - } + private val localParticipantMessageListener = SignalingMessageReceiver.LocalParticipantMessageListener { token -> + if (CallActivity.active) { + Log.d(TAG, "CallActivity is running. Ignore to switch chat in ChatActivity...") + } else { + switchToRoom( + token = token, + startCallAfterRoomSwitch = false, + isVoiceOnlyCall = false + ) } } @@ -485,6 +520,17 @@ class ChatActivity : updateTypingIndicator() } } + + override fun onChatMessageReceived(chatMessage: ChatMessageJson) { + chatViewModel.onSignalingChatMessageReceived(chatMessage) + + Log.d( + TAG, + "received message in ChatActivity. This is the chat message received via HPB. It would be " + + "nicer to receive it in the ViewModel or Repository directly. " + + "Otherwise it needs to be passed into it from here..." + ) + } } override fun onCreate(savedInstanceState: Bundle?) { @@ -495,6 +541,10 @@ class ChatActivity : setupActionBar() setContentView(binding.root) + binding.progressBar.visibility = View.GONE + binding.offline.root.visibility = View.GONE + binding.messagesListView.visibility = View.VISIBLE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { ViewCompat.setOnApplyWindowInsetsListener(binding.chatContainer) { view, insets -> val systemBarInsets = insets.getInsets( @@ -519,12 +569,14 @@ class ChatActivity : colorizeNavigationBar() } - chatViewModel = ViewModelProvider(this, viewModelFactory)[ChatViewModel::class.java] - conversationInfoViewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java] contextChatViewModel = ViewModelProvider(this, viewModelFactory)[ContextChatViewModel::class.java] + if (useJetpackCompose) { + setChatListContent() + } + lifecycleScope.launch { currentUserProvider.getCurrentUser() .onSuccess { user -> @@ -532,11 +584,11 @@ class ChatActivity : handleIntent(intent) val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken) val credentials = ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token) + // TODO init via viewModel parameters, just like it's done for roomToken chatViewModel.initData( user, credentials!!, urlForChatting, - roomToken, conversationThreadId ) @@ -567,10 +619,133 @@ class ChatActivity : Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() } } - binding.progressBar.visibility = View.VISIBLE + + // binding.progressBar.visibility = View.VISIBLE onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } + private fun setChatListContent() { + binding.messagesListViewCompose.setContent { + val uiState by chatViewModel.uiState.collectAsStateWithLifecycle() + // val conversationUiState by chatViewModel.conversationUiState.collectAsStateWithLifecycle() + + currentConversation = uiState.conversation + + // when (conversationUiState) { + // ConversationUiState.Loading -> {} + // ConversationUiState.Empty -> {} + // is ConversationUiState.Success -> { + // currentConversation = (conversationUiState as ConversationUiState.Success).data + // } + // } + + binding.messagesListViewCompose.visibility = View.VISIBLE + binding.messagesListView.visibility = View.GONE + + CompositionLocalProvider( + LocalViewThemeUtils provides viewThemeUtils, + LocalMessageUtils provides messageUtils + ) { + val showAvatar = uiState.showChatAvatars + Log.d(TAG, "showAvatar=" + showAvatar) + + GetNewChatView( + chatItems = uiState.items, + showAvatar = showAvatar, + conversationThreadId = conversationThreadId, + onLoadMore = { loadMoreMessagesCompose() }, + advanceLocalLastReadMessageIfNeeded = { advanceLocalLastReadMessageIfNeeded(it) }, + updateRemoteLastReadMessageIfNeeded = { updateRemoteLastReadMessageIfNeeded() } + ) + } + } + } + + // lifecycleScope.launch { + // chatViewModel.getConversationFlow + // .onEach { conversationModel -> + // currentConversation = conversationModel + // + // // this should be updated in viewModel directly! + // // chatViewModel.updateConversation(conversationModel) + // + // logConversationInfos("GetRoomSuccessState") + // + // if (adapter == null && !useJetpackCompose) { + // initAdapter() + // binding.messagesListView.setAdapter(adapter) + // layoutManager = binding.messagesListView.layoutManager as? LinearLayoutManager + // + // setChatListContentForChatKit() + // } + // + // chatViewModel.getCapabilities(conversationUser!!, roomToken, conversationModel) + // } + // .flatMapLatest { conversationModel -> + // if (conversationModel.lastPinnedId != null && + // conversationModel.lastPinnedId != 0L && + // conversationModel.lastPinnedId != conversationModel.hiddenPinnedId + // ) { + // chatViewModel.getIndividualMessageFromServer( + // credentials!!, + // conversationUser?.baseUrl!!, + // roomToken, + // conversationModel.lastPinnedId.toString() + // ) + // } else { + // flowOf(null) + // } + // } + // .collectLatest { message -> + // if (message != null) { + // binding.pinnedMessageContainer.visibility = View.VISIBLE + // binding.pinnedMessageComposeView.setContent { + // PinnedMessageView( + // message, + // viewThemeUtils, + // currentConversation, + // scrollToMessageWithIdWithOffset = ::scrollToMessageWithIdWithOffset, + // hidePinnedMessage = ::hidePinnedMessage, + // unPinMessage = ::unPinMessage + // ) + // } + // } else { + // binding.pinnedMessageContainer.visibility = View.GONE + // } + // } + // } + + private fun setChatListContentForChatKit() { + binding.messagesListViewCompose.setContent { + val messages by chatViewModel.messagesForChatKit.collectAsStateWithLifecycle(emptyList()) + + val chatMessages = remember(messages) { + messages + .let(::handleSystemMessages) + .let(::handleThreadMessages) + .let(::determinePreviousMessageIds) + .let(::handleExpandableSystemMessages) + .let(::groupAndEnrichMessages) + } + + binding.messagesListViewCompose.visibility = View.GONE + binding.messagesListView.visibility = View.VISIBLE + + // use old ChatKit implementation (production for now) + if (adapter != null) { + // Clearing and adding everything is a temporary solution and not ideal. + // It is done to prepare to replace ChatKit and XML with Jetpack Compose. + // As we "only" add the messages from the latest chatblock, the performance is quite okay. + // With Jetpack Compose the flow will be used directly in the UI instead to clear and add everything. + adapter!!.clear() + adapter!!.addToEnd(chatMessages, false) + advanceLocalLastReadMessageIfNeededChatKit() + } else { + Log.e(TAG, "adapter was null") + } + } + } + private fun getMessageInputFragment(): MessageInputFragment { val internalId = conversationUser!!.id.toString() + "@" + roomToken return MessageInputFragment().apply { @@ -603,14 +778,6 @@ class ChatActivity : private fun handleIntent(intent: Intent) { val extras: Bundle? = intent.extras - roomToken = extras?.getString(KEY_ROOM_TOKEN).orEmpty() - - conversationThreadId = if (extras?.containsKey(KEY_THREAD_ID) == true) { - extras.getLong(KEY_THREAD_ID) - } else { - null - } - openedViaNotification = extras?.getBoolean(KEY_OPENED_VIA_NOTIFICATION) ?: false sharedText = extras?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty() @@ -656,71 +823,33 @@ class ChatActivity : this.lifecycle.removeObserver(chatViewModel) } - @OptIn(ExperimentalCoroutinesApi::class) + @OptIn(FlowPreview::class) @SuppressLint("NotifyDataSetChanged", "SetTextI18n", "ResourceAsColor") @Suppress("LongMethod") private fun initObservers() { Log.d(TAG, "initObservers Called") - lifecycleScope.launch { - chatViewModel.getConversationFlow - .onEach { conversationModel -> - currentConversation = conversationModel - chatViewModel.updateConversation(conversationModel) - logConversationInfos("GetRoomSuccessState") - - if (adapter == null) { - initAdapter() - binding.messagesListView.setAdapter(adapter) - layoutManager = binding.messagesListView.layoutManager as? LinearLayoutManager - } - chatViewModel.getCapabilities(conversationUser!!, roomToken, conversationModel) - } - .flatMapLatest { conversationModel -> - if (conversationModel.lastPinnedId != null && - conversationModel.lastPinnedId != 0L && - conversationModel.lastPinnedId != conversationModel.hiddenPinnedId - ) { - chatViewModel.getIndividualMessageFromServer( - credentials!!, - conversationUser?.baseUrl!!, - roomToken, - conversationModel.lastPinnedId.toString() - ) - } else { - flowOf(null) - } - } - .collectLatest { message -> - if (message != null) { - binding.pinnedMessageContainer.visibility = View.VISIBLE - binding.pinnedMessageComposeView.setContent { - PinnedMessageView( - message, - viewThemeUtils, - currentConversation, - scrollToMessageWithIdWithOffset = ::scrollToMessageWithIdWithOffset, - hidePinnedMessage = ::hidePinnedMessage, - unPinMessage = ::unPinMessage - ) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + chatViewModel.events.collect { event -> + when (event) { + is ChatViewModel.ChatEvent.Initial -> { + // binding.progressBar.visibility = View.GONE + // binding.offline.root.visibility = View.GONE + // binding.messagesListView.visibility = View.VISIBLE + } + is ChatViewModel.ChatEvent.StartRegularPolling -> { + chatViewModel.startMessagePolling( + WebSocketConnectionHelper.getWebSocketInstanceForUser( + conversationUser + ) != null + ) + } + else -> {} } - } else { - binding.pinnedMessageContainer.visibility = View.GONE } } - } - - chatViewModel.getRoomViewState.observe(this) { state -> - when (state) { - is ChatViewModel.GetRoomSuccessState -> { - // unused atm - } - - is ChatViewModel.GetRoomErrorState -> { - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } - - else -> {} } } @@ -746,120 +875,106 @@ class ChatActivity : } is ChatViewModel.GetCapabilitiesInitialLoadState -> { - if (currentConversation != null) { - spreedCapabilities = state.spreedCapabilities - chatApiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) - participantPermissions = ParticipantPermissions(spreedCapabilities, currentConversation!!) - - supportFragmentManager.commit { - setReorderingAllowed(true) // optimizes out redundant replace operations - replace(R.id.fragment_container_activity_chat, messageInputFragment) - runOnCommit { - if (focusInput) { - messageInputFragment.binding.fragmentMessageInputView.requestFocus() - } + spreedCapabilities = state.spreedCapabilities + currentConversation = state.conversationModel + chatApiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) + participantPermissions = ParticipantPermissions(spreedCapabilities, state.conversationModel!!) + + supportFragmentManager.commit { + setReorderingAllowed(true) // optimizes out redundant replace operations + replace(R.id.fragment_container_activity_chat, messageInputFragment) + runOnCommit { + if (focusInput) { + messageInputFragment.binding.fragmentMessageInputView.requestFocus() } } + } - joinRoomWithPassword() + joinRoomWithPassword() - if (conversationUser?.userId != "?" && - hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MENTION_FLAG) && - !isChatThread() - ) { - binding.chatToolbar.setOnClickListener { _ -> showConversationInfoScreen() } - } - refreshScheduledMessages() - - loadAvatarForStatusBar() - setupSwipeToReply() - setActionBarTitle() - isEventConversation() - checkShowCallButtons() - checkLobbyState() - if (currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL && - currentConversation?.status == "dnd" - ) { - conversationUser?.let { user -> - val credentials = ApiUtils.getCredentials(user.username, user.token) - chatViewModel.outOfOfficeStatusOfUser( - credentials!!, - user.baseUrl!!, - currentConversation!!.name - ) - } - } + if (conversationUser?.userId != "?" && + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MENTION_FLAG) && + !isChatThread() + ) { + binding.chatToolbar.setOnClickListener { _ -> showConversationInfoScreen() } + } + refreshScheduledMessages() - if (currentConversation?.objectType == ConversationEnums.ObjectType.EVENT && - hasSpreedFeatureCapability( - conversationUser?.capabilities!!.spreedCapability!!, - SpreedFeatures.UNBIND_CONVERSATION + loadAvatarForStatusBar() + setupSwipeToReply() + setActionBarTitle() + isEventConversation() + checkShowCallButtons() + checkLobbyState() + if (state.conversationModel.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL && + state.conversationModel.status == "dnd" + ) { + conversationUser.let { user -> + val credentials = ApiUtils.getCredentials(user.username, user.token) + chatViewModel.outOfOfficeStatusOfUser( + credentials!!, + user.baseUrl!!, + state.conversationModel!!.name ) - ) { - val eventEndTimeStamp = - currentConversation?.objectId - ?.split("#") - ?.getOrNull(1) - ?.toLongOrNull() - val currentTimeStamp = (System.currentTimeMillis() / ONE_SECOND_IN_MILLIS).toLong() - val retentionPeriod = retentionOfEventRooms(spreedCapabilities) - val isPastEvent = eventEndTimeStamp?.let { it < currentTimeStamp } - if (isPastEvent == true && retentionPeriod != 0) { - showConversationDeletionWarning(retentionPeriod) - } } + } - if (currentConversation?.objectType == ConversationEnums.ObjectType.PHONE_TEMPORARY && - hasSpreedFeatureCapability( - conversationUser?.capabilities!!.spreedCapability!!, - SpreedFeatures.UNBIND_CONVERSATION - ) - ) { - val retentionPeriod = retentionOfSIPRoom(spreedCapabilities) - val systemMessage = currentConversation?.lastMessage?.systemMessageType - if (retentionPeriod != 0 && - ( - systemMessage == ChatMessage.SystemMessageType.CALL_ENDED || - systemMessage == ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE - ) - ) { - showConversationDeletionWarning(retentionPeriod) - } + if (state.conversationModel.objectType == ConversationEnums.ObjectType.EVENT && + hasSpreedFeatureCapability( + conversationUser?.capabilities!!.spreedCapability!!, + SpreedFeatures.UNBIND_CONVERSATION + ) + ) { + val eventEndTimeStamp = + state.conversationModel?.objectId + ?.split("#") + ?.getOrNull(1) + ?.toLongOrNull() + val currentTimeStamp = (System.currentTimeMillis() / ONE_SECOND_IN_MILLIS).toLong() + val retentionPeriod = retentionOfEventRooms(spreedCapabilities) + val isPastEvent = eventEndTimeStamp?.let { it < currentTimeStamp } + if (isPastEvent == true && retentionPeriod != 0) { + showConversationDeletionWarning(retentionPeriod) } + } - if (currentConversation?.objectType == ConversationEnums.ObjectType.INSTANT_MEETING && - hasSpreedFeatureCapability( - conversationUser?.capabilities!!.spreedCapability!!, - SpreedFeatures.UNBIND_CONVERSATION - ) + if (state.conversationModel.objectType == ConversationEnums.ObjectType.PHONE_TEMPORARY && + hasSpreedFeatureCapability( + conversationUser?.capabilities!!.spreedCapability!!, + SpreedFeatures.UNBIND_CONVERSATION + ) + ) { + val retentionPeriod = retentionOfSIPRoom(spreedCapabilities) + val systemMessage = currentConversation?.lastMessage?.systemMessageType + if (retentionPeriod != 0 && + ( + systemMessage == ChatMessage.SystemMessageType.CALL_ENDED || + systemMessage == ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE + ) ) { - val retentionPeriod = retentionOfInstantMeetingRoom(spreedCapabilities) - val systemMessage = currentConversation?.lastMessage?.systemMessageType - if (retentionPeriod != 0 && - ( - systemMessage == ChatMessage.SystemMessageType.CALL_ENDED || - systemMessage == ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE - ) - ) { - showConversationDeletionWarning(retentionPeriod) - } + showConversationDeletionWarning(retentionPeriod) } + } - updateRoomTimerHandler(MILLIS_250) - - val urlForChatting = - ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken) - - chatViewModel.loadMessages( - withCredentials = credentials!!, - withUrl = urlForChatting - ) - } else { - Log.w( - TAG, - "currentConversation was null in observer ChatViewModel.GetCapabilitiesInitialLoadState" + if (state.conversationModel.objectType == ConversationEnums.ObjectType.INSTANT_MEETING && + hasSpreedFeatureCapability( + conversationUser?.capabilities!!.spreedCapability!!, + SpreedFeatures.UNBIND_CONVERSATION ) + ) { + val retentionPeriod = retentionOfInstantMeetingRoom(spreedCapabilities) + val systemMessage = state.conversationModel.lastMessage?.systemMessageType + if (retentionPeriod != 0 && + ( + systemMessage == ChatMessage.SystemMessageType.CALL_ENDED || + systemMessage == ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE + ) + ) { + showConversationDeletionWarning(retentionPeriod) + } } + + updateRoomTimerHandler(MILLIS_250) } is ChatViewModel.GetCapabilitiesErrorState -> { @@ -1007,7 +1122,7 @@ class ChatActivity : val id = state.msg.ocs!!.data!!.parentMessage!!.id.toString() val index = adapter?.getMessagePositionById(id) ?: 0 - val message = adapter?.items?.get(index)?.item as ChatMessage + val message = adapter?.items?.get(index)?.item as? ChatMessage setMessageAsDeleted(message) } @@ -1041,77 +1156,6 @@ class ChatActivity : } } - chatViewModel.chatMessageViewState.observe(this) { state -> - when (state) { - is ChatViewModel.ChatMessageStartState -> { - // Handle UI on first load - cancelNotificationsForCurrentConversation() - binding.progressBar.visibility = View.GONE - binding.offline.root.visibility = View.GONE - binding.messagesListView.visibility = View.VISIBLE - collapseSystemMessages() - } - - is ChatViewModel.ChatMessageUpdateState -> { - binding.progressBar.visibility = View.GONE - binding.offline.root.visibility = View.GONE - binding.messagesListView.visibility = View.VISIBLE - } - - is ChatViewModel.ChatMessageErrorState -> { - // unused atm - } - - else -> {} - } - } - - this.lifecycleScope.launch { - chatViewModel.getMessageFlow - .onEach { triple -> - val lookIntoFuture = triple.first - val setUnreadMessagesMarker = triple.second - var chatMessageList = triple.third - - chatMessageList = handleSystemMessages(chatMessageList) - chatMessageList = handleThreadMessages(chatMessageList) - if (chatMessageList.isEmpty()) { - return@onEach - } - - determinePreviousMessageIds(chatMessageList) - - handleExpandableSystemMessages(chatMessageList) - - if (ChatMessage.SystemMessageType.CLEARED_CHAT == chatMessageList[0].systemMessageType) { - adapter?.clear() - adapter?.notifyDataSetChanged() - } - - if (lookIntoFuture) { - Log.d(TAG, "chatMessageList.size in getMessageFlow:" + chatMessageList.size) - processMessagesFromTheFuture(chatMessageList, setUnreadMessagesMarker) - } else { - processMessagesNotFromTheFuture(chatMessageList) - collapseSystemMessages() - } - - processExpiredMessages() - processCallStartedMessages() - - adapter?.notifyDataSetChanged() - } - .collect() - } - - this.lifecycleScope.launch { - chatViewModel.getRemoveMessageFlow - .onEach { - removeMessageById(it.id) - } - .collect() - } - this.lifecycleScope.launch { chatViewModel.getUpdateMessageFlow .onEach { @@ -1137,20 +1181,6 @@ class ChatActivity : .collect() } - this.lifecycleScope.launch { - chatViewModel.getGeneralUIFlow.onEach { key -> - when (key) { - NO_OFFLINE_MESSAGES_FOUND -> { - binding.progressBar.visibility = View.GONE - binding.messagesListView.visibility = View.GONE - binding.offline.root.visibility = View.VISIBLE - } - - else -> {} - } - }.collect() - } - this.lifecycleScope.launch { chatViewModel.mediaPlayerSeekbarObserver.onEach { msg -> adapter?.update(msg) @@ -1405,11 +1435,14 @@ class ChatActivity : } private fun removeUnreadMessagesMarker() { + chatViewModel.setUnreadMessagesMarker(false) + removeMessageById(UNREAD_MESSAGES_MARKER_ID.toString()) } // do not use adapter.deleteById() as it seems to contain a bug! Use this method instead! @Suppress("MagicNumber") + @Deprecated("old chatkit handling") private fun removeMessageById(idToDelete: String) { val indexToDelete = adapter?.getMessagePositionById(idToDelete) if (indexToDelete != null && indexToDelete != UNREAD_MESSAGES_MARKER_ID) { @@ -1530,6 +1563,8 @@ class ChatActivity : super.onScrollStateChanged(recyclerView, newState) if (newState == AbsListView.OnScrollListener.SCROLL_STATE_IDLE) { + advanceLocalLastReadMessageIfNeededChatKit() + updateRemoteLastReadMessageIfNeeded() if (isScrolledToBottom()) { binding.unreadMessagesPopup.visibility = View.GONE binding.scrollDownButton.visibility = View.GONE @@ -2837,9 +2872,50 @@ class ChatActivity : if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { mentionAutocomplete?.dismissPopup() } + + // TODO: when updating remote last read message in onPause, there is a race condition with loading conversations + // for conversation list. It may or may not include info about the sent last read message... + // -> save this field offline in conversation. when getting new conversations, do not overwrite + // lastReadMessage if offline has higher value + updateRemoteLastReadMessageIfNeeded() + adapter = null } + @Deprecated("old implementation for ChatKit") + private fun advanceLocalLastReadMessageIfNeededChatKit() { + val position = layoutManager?.findFirstVisibleItemPosition() + position?.let { + // Casting could fail if it's not a chatMessage. It should not matter as the function is triggered often + // enough. If it's a problem, either improve or wait for migration to Jetpack Compose. + val message = adapter?.items?.getOrNull(it)?.item as? ChatMessage + message?.jsonMessageId?.let { messageId -> + advanceLocalLastReadMessageIfNeeded(messageId) + } + } + } + + private fun advanceLocalLastReadMessageIfNeeded(messageId: Int) { + chatViewModel.advanceLocalLastReadMessageIfNeeded(messageId) + } + + private fun updateRemoteLastReadMessageIfNeeded() { + if (this::spreedCapabilities.isInitialized) { + spreedCapabilities?.let { + val url = ApiUtils.getUrlForChatReadMarker( + ApiUtils.getChatApiVersion(it, intArrayOf(ApiUtils.API_V1)), + conversationUser.baseUrl!!, + roomToken + ) + + chatViewModel.updateRemoteLastReadMessageIfNeeded( + credentials = credentials!!, + url = url + ) + } + } + } + private fun isActivityNotChangingConfigurations(): Boolean = !isChangingConfigurations private fun isNotInCall(): Boolean = @@ -2966,6 +3042,7 @@ class ChatActivity : private fun setupWebsocket() { if (currentConversation == null || conversationUser == null) { + Log.e(TAG, "setupWebsocket: currentConversation or conversationUser is null") return } @@ -3123,62 +3200,62 @@ class ChatActivity : } } - private fun processMessagesFromTheFuture(chatMessageList: List, setUnreadMessagesMarker: Boolean) { - binding.scrollDownButton.visibility = View.GONE - - val scrollToBottom: Boolean - - if (setUnreadMessagesMarker) { - scrollToBottom = false - setUnreadMessageMarker(chatMessageList) - } else { - if (isScrolledToBottom()) { - scrollToBottom = true - } else { - scrollToBottom = false - binding.unreadMessagesPopup.visibility = View.VISIBLE - // here we have the problem that the chat jumps for every update - } - } - - var shouldRefreshRoom = false - - for (chatMessage in chatMessageList) { - chatMessage.activeUser = conversationUser - - adapter?.let { - val previousChatMessage = it.items?.getOrNull(1)?.item - if (previousChatMessage != null && previousChatMessage is ChatMessage) { - chatMessage.isGrouped = groupMessages(chatMessage, previousChatMessage) - } - chatMessage.isOneToOneConversation = - (currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) - chatMessage.isFormerOneToOneConversation = - (currentConversation?.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE) - Log.d(TAG, "chatMessage to add:" + chatMessage.message) - it.addToStart(chatMessage, scrollToBottom) - } - - val systemMessageType = chatMessage.systemMessageType - if (systemMessageType != null && - ( - systemMessageType == ChatMessage.SystemMessageType.MESSAGE_PINNED || - systemMessageType == ChatMessage.SystemMessageType.MESSAGE_UNPINNED - ) - ) { - shouldRefreshRoom = true - } - } - - if (shouldRefreshRoom) { - chatViewModel.refreshRoom() - } - - // workaround to jump back to unread messages marker - if (setUnreadMessagesMarker) { - scrollToFirstUnreadMessage() - } - } + // private fun processMessagesFromTheFuture(chatMessageList: List, setUnreadMessagesMarker: Boolean) { + // binding.scrollDownButton.visibility = View.GONE + // + // val scrollToBottom: Boolean + // + // if (setUnreadMessagesMarker) { + // scrollToBottom = false + // setUnreadMessageMarker(chatMessageList) + // } else { + // if (isScrolledToBottom()) { + // scrollToBottom = true + // } else { + // scrollToBottom = false + // binding.unreadMessagesPopup.visibility = View.VISIBLE + // // here we have the problem that the chat jumps for every update + // } + // } + // + // var shouldRefreshRoom = false + // + // for (chatMessage in chatMessageList) { + // chatMessage.activeUser = conversationUser + // + // adapter?.let { + // val previousChatMessage = it.items?.getOrNull(1)?.item + // if (previousChatMessage != null && previousChatMessage is ChatMessage) { + // chatMessage.isGrouped = groupMessages(chatMessage, previousChatMessage) + // } + // chatMessage.isOneToOneConversation = + // (currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) + // chatMessage.isFormerOneToOneConversation = + // (currentConversation?.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE) + // Log.d(TAG, "chatMessage to add:" + chatMessage.message) + // it.addToStart(chatMessage, scrollToBottom) + // } + // + // val systemMessageType = chatMessage.systemMessageType + // if (systemMessageType != null && + // ( + // systemMessageType == ChatMessage.SystemMessageType.MESSAGE_PINNED || + // systemMessageType == ChatMessage.SystemMessageType.MESSAGE_UNPINNED + // ) + // ) { + // shouldRefreshRoom = true + // } + // } + // + // if (shouldRefreshRoom) { + // chatViewModel.refreshRoom() + // } + // + // // workaround to jump back to unread messages marker + // if (setUnreadMessagesMarker) { + // scrollToFirstUnreadMessage() + // } + // } private fun isScrolledToBottom(): Boolean { val position = layoutManager?.findFirstVisibleItemPosition() @@ -3205,26 +3282,26 @@ class ChatActivity : } } - private fun processMessagesNotFromTheFuture(chatMessageList: List) { - for (i in chatMessageList.indices) { - if (chatMessageList.size > i + 1) { - chatMessageList[i].isGrouped = groupMessages(chatMessageList[i], chatMessageList[i + 1]) - } - - val chatMessage = chatMessageList[i] - chatMessage.isOneToOneConversation = - currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL - chatMessage.isFormerOneToOneConversation = - (currentConversation?.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE) - chatMessage.activeUser = conversationUser - chatMessage.token = roomToken - } - - if (adapter != null) { - adapter?.addToEnd(chatMessageList, false) - } - scrollToRequestedMessageIfNeeded() - } + // private fun processMessagesNotFromTheFuture(chatMessageList: List) { + // for (i in chatMessageList.indices) { + // if (chatMessageList.size > i + 1) { + // chatMessageList[i].isGrouped = groupMessages(chatMessageList[i], chatMessageList[i + 1]) + // } + // + // val chatMessage = chatMessageList[i] + // chatMessage.isOneToOneConversation = + // currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + // chatMessage.isFormerOneToOneConversation = + // (currentConversation?.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE) + // chatMessage.activeUser = conversationUser + // chatMessage.token = roomToken + // } + // + // if (adapter != null) { + // adapter?.addToEnd(chatMessageList, false) + // } + // scrollToRequestedMessageIfNeeded() + // } private fun scrollToFirstUnreadMessage() { adapter?.let { @@ -3232,38 +3309,7 @@ class ChatActivity : } } - private fun groupMessages(message1: ChatMessage, message2: ChatMessage): Boolean { - val message1IsSystem = message1.systemMessage.isNotEmpty() - val message2IsSystem = message2.systemMessage.isNotEmpty() - if (message1IsSystem != message2IsSystem) { - return false - } - - if (message1.actorType == "bots" && message1.actorId != "changelog") { - return false - } - - if (!message1IsSystem && - ( - (message1.actorType != message2.actorType) || - (message2.actorId != message1.actorId) - ) - ) { - return false - } - - val timeDifference = dateUtils.getTimeDifferenceInSeconds( - message2.timestamp, - message1.timestamp - ) - val isLessThan5Min = timeDifference > FIVE_MINUTES_IN_SECONDS - return isSameDayMessages(message2, message1) && - (message2.actorId == message1.actorId) && - (!isLessThan5Min) && - (message2.lastEditTimestamp == 0L || message1.lastEditTimestamp == 0L) - } - - private fun determinePreviousMessageIds(chatMessageList: List) { + private fun determinePreviousMessageIds(chatMessageList: List): List { var previousMessageId = NO_PREVIOUS_MESSAGE_ID for (i in chatMessageList.indices.reversed()) { val chatMessage = chatMessageList[i] @@ -3284,6 +3330,7 @@ class ChatActivity : previousMessageId = chatMessage.jsonMessageId } + return chatMessageList } private fun getItemFromAdapter(messageId: String): Pair? { @@ -3292,7 +3339,7 @@ class ChatActivity : it.item is ChatMessage && (it.item as ChatMessage).id == messageId } if (messagePosition >= 0) { - val currentItem = adapter?.items?.get(messagePosition)?.item + val currentItem = adapter?.items?.getOrNull(messagePosition)?.item if (currentItem is ChatMessage && currentItem.id == messageId) { return Pair(currentItem, messagePosition) } else { @@ -3321,6 +3368,37 @@ class ChatActivity : private fun isSameDayMessages(message1: ChatMessage, message2: ChatMessage): Boolean = DateFormatter.isSameDay(message1.createdAt, message2.createdAt) + private fun loadMoreMessagesCompose() { + chatViewModel.loadMoreMessagesCompose() + } + + // private fun loadMoreMessagesCompose() { + // val currentMessages = chatViewModel.chatItems.value + // + // val messageId = currentMessages + // .lastOrNull() + // ?.jsonMessageId + // + // Log.d("newchat", "Compose load more, messageId: $messageId") + // + // messageId?.let { + // val urlForChatting = ApiUtils.getUrlForChat( + // chatApiVersion, + // conversationUser?.baseUrl, + // roomToken + // ) + // + // chatViewModel.loadMoreMessages( + // beforeMessageId = it.toLong(), + // withUrl = urlForChatting, + // withCredentials = credentials!!, + // withMessageLimit = MESSAGE_PULL_LIMIT, + // roomToken = currentConversation!!.token + // ) + // } + // } + + @Deprecated("old adapter solution") override fun onLoadMore(page: Int, totalItemsCount: Int) { val messageId = ( adapter?.items @@ -3328,6 +3406,8 @@ class ChatActivity : ?.item as? ChatMessage )?.jsonMessageId + Log.d("newchat", "onLoadMore with messageId: " + messageId + " page:$page totalItemsCount:$totalItemsCount") + messageId?.let { val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken) @@ -3898,6 +3978,55 @@ class ChatActivity : return chatMessageMap.values.toList() } + private fun groupAndEnrichMessages(chatMessageList: List): List { + fun groupMessages(message1: ChatMessage, message2: ChatMessage): Boolean { + val message1IsSystem = message1.systemMessage.isNotEmpty() + val message2IsSystem = message2.systemMessage.isNotEmpty() + if (message1IsSystem != message2IsSystem) { + return false + } + + if (message1.actorType == "bots" && message1.actorId != "changelog") { + return false + } + + if (!message1IsSystem && + ( + (message1.actorType != message2.actorType) || + (message2.actorId != message1.actorId) + ) + ) { + return false + } + + val timeDifference = dateUtils.getTimeDifferenceInSeconds( + message2.timestamp, + message1.timestamp + ) + val isLessThan5Min = timeDifference > FIVE_MINUTES_IN_SECONDS + return isSameDayMessages(message2, message1) && + (message2.actorId == message1.actorId) && + (!isLessThan5Min) && + (message2.lastEditTimestamp == 0L || message1.lastEditTimestamp == 0L) + } + + for (i in chatMessageList.indices) { + if (chatMessageList.size > i + 1) { + chatMessageList[i].isGrouped = groupMessages(chatMessageList[i], chatMessageList[i + 1]) + } + val chatMessage = chatMessageList[i] + + chatMessage.isOneToOneConversation = + currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + chatMessage.isFormerOneToOneConversation = + (currentConversation?.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE) + + chatMessage.activeUser = conversationUser + chatMessage.token = roomToken + } + return chatMessageList + } + private fun groupSystemMessages(previousMessage: ChatMessage, currentMessage: ChatMessage) { previousMessage.expandableParent = true currentMessage.expandableParent = false @@ -4142,7 +4271,10 @@ class ChatActivity : binding.genericComposeView.apply { val shouldDismiss = mutableStateOf(false) setContent { - DateTimeCompose(bundle).GetDateTimeDialog(shouldDismiss, this@ChatActivity) + DateTimeCompose( + bundle, + chatViewModel + ).GetDateTimeDialog(shouldDismiss, this@ChatActivity) } } } @@ -4173,10 +4305,25 @@ class ChatActivity : chatViewModel.unPinMessage(credentials!!, url) } + private fun markAsRead(messageId: Int) { + chatViewModel.setChatReadMessage( + credentials!!, + ApiUtils.getUrlForChatReadMarker( + ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(ApiUtils.API_V1)), + conversationUser?.baseUrl!!, + roomToken + ), + messageId + ) + } + fun markAsUnread(message: IMessage?) { val chatMessage = message as ChatMessage? if (chatMessage!!.previousMessageId > NO_PREVIOUS_MESSAGE_ID) { - chatViewModel.setChatReadMarker( + // previousMessageId is taken to mark chat as unread even when "chat-unread" capability is not available + // It should be checked if "chat-unread" capability is available and then use + // https://nextcloud-talk.readthedocs.io/en/latest/chat/#mark-chat-as-unread + chatViewModel.setChatReadMessage( credentials!!, ApiUtils.getUrlForChatReadMarker( ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(ApiUtils.API_V1)), diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index 44d92b58cbc..3164697b637 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -1001,6 +1001,8 @@ class MessageInputFragment : Fragment() { } private fun sendMessage(message: String, sendWithoutNotification: Boolean) { + chatActivity.chatViewModel.onMessageSent() + messageInputViewModel.sendChatMessage( credentials = chatActivity.conversationUser!!.getCredentials(), url = ApiUtils.getUrlForChat( diff --git a/app/src/main/java/com/nextcloud/talk/chat/UnreadMessagesPopup.kt b/app/src/main/java/com/nextcloud/talk/chat/UnreadMessagesPopup.kt new file mode 100644 index 00000000000..67e48e2121f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/UnreadMessagesPopup.kt @@ -0,0 +1,54 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextcloud.talk.R + +@Composable +fun UnreadMessagesPopup(onClick: () -> Unit, modifier: Modifier = Modifier) { + Button( + onClick = onClick, + modifier = modifier, + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Icon( + imageVector = Icons.Default.ArrowDownward, + contentDescription = null + ) + Text(text = stringResource(id = R.string.nc_new_messages)) + } + } +} + +@Preview +@Composable +fun UnreadMessagesPopupPreview() { + UnreadMessagesPopup(onClick = {}) +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt index f040b166589..f6a73fd3d0f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt @@ -10,11 +10,12 @@ package com.nextcloud.talk.chat.data import android.os.Bundle import com.nextcloud.talk.chat.data.io.LifecycleAwareManager import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage import com.nextcloud.talk.models.json.generic.GenericOverall -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @Suppress("TooManyFunctions") @@ -39,19 +40,21 @@ interface ChatMessageRepository : LifecycleAwareManager { val lastReadMessageFlow: Flow - /** - * Used for informing the user of the underlying processing behind offline support, [String] is the key - * which is handled in a switch statement in ChatActivity. - */ - val generalUIFlow: Flow + // /** + // * Used for informing the user of the underlying processing behind offline support, [String] is the key + // * which is handled in a switch statement in ChatActivity. + // */ + // val generalUIFlow: Flow - val removeMessageFlow: Flow + // val removeMessageFlow: Flow fun initData(currentUser: User, credentials: String, urlForChatting: String, roomToken: String, threadId: Long?) fun updateConversation(conversationModel: ConversationModel) - fun initScopeAndLoadInitialMessages(withNetworkParams: Bundle) + suspend fun loadInitialMessages(withNetworkParams: Bundle, isChatRelaySupported: Boolean) + + suspend fun startMessagePolling(hasHighPerformanceBackend: Boolean) /** * Loads messages from local storage. If the messages are not found, then it @@ -60,27 +63,26 @@ interface ChatMessageRepository : LifecycleAwareManager { * * [withNetworkParams] credentials and url */ - fun loadMoreMessages( + suspend fun loadMoreMessages( beforeMessageId: Long, roomToken: String, withMessageLimit: Int, withNetworkParams: Bundle - ): Job - - /** - * Long polls the server for any updates to the chat, if found, it synchronizes - * the database with the server and emits the new messages to [messageFlow], - * else it simply retries after timeout. - */ - fun initMessagePolling(initialMessageId: Long): Job + ) /** * Gets a individual message. */ - suspend fun getMessage(messageId: Long, bundle: Bundle): Flow + fun getMessage(messageId: Long, bundle: Bundle): Flow + @Deprecated("getMessage(messageId: Long, bundle: Bundle)") suspend fun getParentMessageById(messageId: Long): Flow + suspend fun fetchMissingParents( + conversationId: String, + parentIds: List + ) + suspend fun getNumberOfThreadReplies(threadId: Long): Int @Suppress("LongParameterList") @@ -152,4 +154,8 @@ interface ChatMessageRepository : LifecycleAwareManager { suspend fun deleteScheduledChatMessage(credentials: String, url: String): Flow> suspend fun getScheduledChatMessages(credentials: String, url: String): Flow>> + + suspend fun onSignalingChatMessageReceived(chatMessage: ChatMessageJson) + + fun observeMessages(internalConversationId: String): Flow> } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt index c856ca6977d..f3ab7d99f16 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt @@ -24,8 +24,12 @@ import com.nextcloud.talk.utils.CapabilitiesUtil import com.stfalcon.chatkit.commons.models.IUser import com.stfalcon.chatkit.commons.models.MessageContentType import java.security.MessageDigest +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId import java.util.Date +// Domain model for chat message. No entries here that are only necessary for the database layer, nor only for UI layer data class ChatMessage( var isGrouped: Boolean = false, @@ -74,6 +78,7 @@ data class ChatMessage( var parentMessageId: Long? = null, + @Deprecated("delete with chatkit") var readStatus: Enum = ReadStatus.NONE, var messageType: String? = null, @@ -144,7 +149,11 @@ data class ChatMessage( var pinnedUntil: Long? = null, - var sendAt: Int? = null + var sendAt: Int? = null, + + var avatarUrl: String? = null, + + var isUnread: Boolean = false ) : MessageContentType, MessageContentType.Image { @@ -211,28 +220,33 @@ data class ChatMessage( @Suppress("ReturnCount") fun isLinkPreview(): Boolean { - if (CapabilitiesUtil.isLinkPreviewAvailable(activeUser!!)) { - val regexStringFromServer = activeUser?.capabilities?.coreCapability?.referenceRegex - - val regexFromServer = regexStringFromServer?.toRegex(setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE)) - val regexDefault = REGEX_STRING_DEFAULT.toRegex(setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE)) - - val messageCharSequence: CharSequence = StringBuffer(message!!) + activeUser?.let { + if (CapabilitiesUtil.isLinkPreviewAvailable(it)) { + val regexStringFromServer = activeUser?.capabilities?.coreCapability?.referenceRegex + + val regexFromServer = regexStringFromServer?.toRegex( + setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE) + ) + val regexDefault = REGEX_STRING_DEFAULT.toRegex(setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE)) + + val messageCharSequence: CharSequence = StringBuffer(message!!) + + if (regexFromServer != null) { + val foundLinkInServerRegex = regexFromServer.containsMatchIn(messageCharSequence) + if (foundLinkInServerRegex) { + extractedUrlToPreview = regexFromServer.find(messageCharSequence)?.groups?.get(0)?.value?.trim() + return true + } + } - if (regexFromServer != null) { - val foundLinkInServerRegex = regexFromServer.containsMatchIn(messageCharSequence) - if (foundLinkInServerRegex) { - extractedUrlToPreview = regexFromServer.find(messageCharSequence)?.groups?.get(0)?.value?.trim() + val foundLinkInDefaultRegex = regexDefault.containsMatchIn(messageCharSequence) + if (foundLinkInDefaultRegex) { + extractedUrlToPreview = regexDefault.find(messageCharSequence)?.groups?.get(0)?.value?.trim() return true } } - - val foundLinkInDefaultRegex = regexDefault.containsMatchIn(messageCharSequence) - if (foundLinkInDefaultRegex) { - extractedUrlToPreview = regexDefault.find(messageCharSequence)?.groups?.get(0)?.value?.trim() - return true - } } + return false } @@ -363,6 +377,11 @@ data class ChatMessage( val isDeletedCommentMessage: Boolean get() = "comment_deleted" == messageType + fun ChatMessage.dateKey(): LocalDate = + Instant.ofEpochMilli(timestamp * 1000L) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + enum class MessageType { REGULAR_TEXT_MESSAGE, SYSTEM_MESSAGE, diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt index dfd437e24db..6c6a3ebeadf 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt @@ -64,7 +64,12 @@ interface ChatNetworkDataSource { threadTitle: String? ): ChatOverallSingleMessage - fun pullChatMessages(credentials: String, url: String, fieldMap: HashMap): Observable> + suspend fun pullChatMessages( + credentials: String, + url: String, + fieldMap: HashMap + ): Response + fun deleteChatMessage(credentials: String, url: String): Observable fun createRoom(credentials: String, url: String, map: Map): Observable fun setChatReadMarker(credentials: String, url: String, previousMessageId: Int): Observable diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt index e77cbadc607..352d06136eb 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt @@ -10,13 +10,13 @@ package com.nextcloud.talk.chat.data.network import android.os.Bundle import android.util.Log -import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.chat.data.ChatMessageRepository import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.chat.domain.ChatPullResult import com.nextcloud.talk.data.database.dao.ChatBlocksDao import com.nextcloud.talk.data.database.dao.ChatMessagesDao import com.nextcloud.talk.data.database.mappers.asEntity -import com.nextcloud.talk.data.database.mappers.asModel +import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.data.database.model.ChatBlockEntity import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.data.database.model.SendStatus @@ -25,31 +25,35 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.toIntOrZero import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.chat.ChatMessageJson -import com.nextcloud.talk.models.json.chat.ChatOverall import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.Participant import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.message.SendMessageUtils -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.schedulers.Schedulers -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.timeout +import retrofit2.HttpException import java.io.IOException import javax.inject.Inject +import kotlin.collections.map +import kotlin.time.Duration.Companion.microseconds @Suppress("LargeClass", "TooManyFunctions") class OfflineFirstChatRepository @Inject constructor( @@ -86,8 +90,7 @@ class OfflineFirstChatRepository @Inject constructor( private val _updateMessageFlow: MutableSharedFlow = MutableSharedFlow() - override val lastCommonReadFlow: - Flow + override val lastCommonReadFlow: Flow get() = _lastCommonReadFlow private val _lastCommonReadFlow: @@ -99,20 +102,19 @@ class OfflineFirstChatRepository @Inject constructor( private val _lastReadMessageFlow: MutableSharedFlow = MutableSharedFlow() - override val generalUIFlow: Flow - get() = _generalUIFlow + // override val generalUIFlow: Flow + // get() = _generalUIFlow + // + // private val _generalUIFlow: MutableSharedFlow = MutableSharedFlow() - private val _generalUIFlow: MutableSharedFlow = MutableSharedFlow() - - override val removeMessageFlow: Flow - get() = _removeMessageFlow - - private val _removeMessageFlow: - MutableSharedFlow = MutableSharedFlow() + // override val removeMessageFlow: Flow + // get() = _removeMessageFlow + // + // private val _removeMessageFlow: + // MutableSharedFlow = MutableSharedFlow() private var newXChatLastCommonRead: Int? = null private var itIsPaused = false - private lateinit var scope: CoroutineScope lateinit var internalConversationId: String private lateinit var conversationModel: ConversationModel @@ -120,6 +122,10 @@ class OfflineFirstChatRepository @Inject constructor( private lateinit var urlForChatting: String private var threadId: Long? = null + private var latestKnownMessageIdFromSync: Long = 0 + + private val requestedParentIds = mutableSetOf() + override fun initData( currentUser: User, credentials: String, @@ -139,103 +145,108 @@ class OfflineFirstChatRepository @Inject constructor( this.conversationModel = conversationModel } - override fun initScopeAndLoadInitialMessages(withNetworkParams: Bundle) { - scope = CoroutineScope(Dispatchers.IO) - loadInitialMessages(withNetworkParams) - } + override suspend fun loadInitialMessages(withNetworkParams: Bundle, isChatRelaySupported: Boolean) { + Log.d(TAG, "---- loadInitialMessages ------------") + newXChatLastCommonRead = conversationModel.lastCommonReadMessage - private fun loadInitialMessages(withNetworkParams: Bundle): Job = - scope.launch { - Log.d(TAG, "---- loadInitialMessages ------------") - newXChatLastCommonRead = conversationModel.lastCommonReadMessage + Log.d(TAG, "conversationModel.internalId: " + conversationModel.internalId) + Log.d(TAG, "conversationModel.lastReadMessage:" + conversationModel.lastReadMessage) - Log.d(TAG, "conversationModel.internalId: " + conversationModel.internalId) - Log.d(TAG, "conversationModel.lastReadMessage:" + conversationModel.lastReadMessage) + var newestMessageIdFromDb = chatBlocksDao.getNewestMessageIdFromChatBlocks(internalConversationId, threadId) + Log.d(TAG, "newestMessageIdFromDb: $newestMessageIdFromDb") - var newestMessageIdFromDb = chatBlocksDao.getNewestMessageIdFromChatBlocks(internalConversationId, threadId) - Log.d(TAG, "newestMessageIdFromDb: $newestMessageIdFromDb") + val weAlreadyHaveSomeOfflineMessages = newestMessageIdFromDb > 0 - val weAlreadyHaveSomeOfflineMessages = newestMessageIdFromDb > 0 - val weHaveAtLeastTheLastReadMessage = newestMessageIdFromDb >= conversationModel.lastReadMessage.toLong() - Log.d(TAG, "weAlreadyHaveSomeOfflineMessages:$weAlreadyHaveSomeOfflineMessages") - Log.d(TAG, "weHaveAtLeastTheLastReadMessage:$weHaveAtLeastTheLastReadMessage") + val weHaveAtLeastTheLastReadMessage = newestMessageIdFromDb >= conversationModel.lastReadMessage.toLong() + Log.d(TAG, "weAlreadyHaveSomeOfflineMessages:$weAlreadyHaveSomeOfflineMessages") + Log.d(TAG, "weHaveAtLeastTheLastReadMessage:$weHaveAtLeastTheLastReadMessage") + Log.d(TAG, "isChatRelaySupported:$isChatRelaySupported") - if (weAlreadyHaveSomeOfflineMessages && weHaveAtLeastTheLastReadMessage) { + if (weAlreadyHaveSomeOfflineMessages && weHaveAtLeastTheLastReadMessage && !isChatRelaySupported) { + Log.d( + TAG, + "Initial online request is skipped because offline messages are up to date" + + " until lastReadMessage" + ) + + // For messages newer than lastRead, lookIntoFuture will load them. + // We must only end up here when NO HPB is used! + // If a HPB is used, longPolling is not available to handle loading of newer messages. + // When a HPB is used the initial request must be made. + } else { + if (isChatRelaySupported) { Log.d( TAG, - "Initial online request is skipped because offline messages are up to date" + - " until lastReadMessage" + "An online request for newest 100 messages is made because chatRelay is supported (No long " + + "polling available to catch up with messages newer than last read.)" ) - Log.d(TAG, "For messages newer than lastRead, lookIntoFuture will load them.") - } else { - if (!weAlreadyHaveSomeOfflineMessages) { - Log.d(TAG, "An online request for newest 100 messages is made because offline chat is empty") - if (networkMonitor.isOnline.value.not()) { - _generalUIFlow.emit(ChatActivity.NO_OFFLINE_MESSAGES_FOUND) - } - } else { - Log.d( - TAG, - "An online request for newest 100 messages is made because we don't have the lastReadMessage " + - "(gaps could be closed by scrolling up to merge the chatblocks)" - ) + } else if (!weAlreadyHaveSomeOfflineMessages) { + Log.d(TAG, "An online request for newest 100 messages is made because offline chat is empty") + if (networkMonitor.isOnline.value.not()) { + // _generalUIFlow.emit(ChatActivity.NO_OFFLINE_MESSAGES_FOUND) } - - // set up field map to load the newest messages - val fieldMap = getFieldMap( - lookIntoFuture = false, - timeout = 0, - includeLastKnown = true, - setReadMarker = true, - lastKnown = null + } else { + Log.d( + TAG, + "An online request for newest 100 messages is made because we don't have the lastReadMessage " + + "(gaps could be closed by scrolling up to merge the chatblocks)" ) - withNetworkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap) - withNetworkParams.putString(BundleKeys.KEY_ROOM_TOKEN, conversationModel.token) - - Log.d(TAG, "Starting online request for initial loading") - val chatMessageEntities = sync(withNetworkParams) - if (chatMessageEntities == null) { - Log.e(TAG, "initial loading of messages failed") - } - - newestMessageIdFromDb = chatBlocksDao.getNewestMessageIdFromChatBlocks(internalConversationId, threadId) - Log.d(TAG, "newestMessageIdFromDb after sync: $newestMessageIdFromDb") } - handleMessagesFromDb(newestMessageIdFromDb) - - initMessagePolling(newestMessageIdFromDb) - } - - private suspend fun handleMessagesFromDb(newestMessageIdFromDb: Long) { - if (newestMessageIdFromDb.toInt() != 0) { - val limit = getCappedMessagesAmountOfChatBlock(newestMessageIdFromDb) - - val list = getMessagesBeforeAndEqual( - messageId = newestMessageIdFromDb, - internalConversationId = internalConversationId, - messageLimit = limit + // set up field map to load the newest messages + val fieldMap = getFieldMap( + lookIntoFuture = false, + timeout = 0, + includeLastKnown = true, + lastKnown = null ) - if (list.isNotEmpty()) { - handleNewAndTempMessages( - receivedChatMessages = list, - lookIntoFuture = false, - showUnreadMessagesMarker = false - ) - } + withNetworkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap) + withNetworkParams.putString(BundleKeys.KEY_ROOM_TOKEN, conversationModel.token) - // this call could be deleted when we have a worker to send messages.. - sendUnsentChatMessages(credentials, urlForChatting) + Log.d(TAG, "Starting online request for initial loading") + getAndPersistMessages(withNetworkParams) + } - // delay is a dirty workaround to make sure messages are added to adapter on initial load before dealing - // with them (otherwise there is a race condition). - delay(DELAY_TO_ENSURE_MESSAGES_ARE_ADDED) + // handleMessagesFromDb(newestMessageIdFromDb) + } - updateUiForLastCommonRead() - updateUiForLastReadMessage(newestMessageIdFromDb) + override suspend fun startMessagePolling(hasHighPerformanceBackend: Boolean) { + if (hasHighPerformanceBackend) { + initInsuranceRequests() + } else { + initLongPolling() } } + // private suspend fun handleMessagesFromDb(newestMessageIdFromDb: Long) { + // if (newestMessageIdFromDb.toInt() != 0) { + // val limit = getCappedMessagesAmountOfChatBlock(newestMessageIdFromDb) + // + // val list = getMessagesBeforeAndEqual( + // messageId = newestMessageIdFromDb, + // internalConversationId = internalConversationId, + // messageLimit = limit + // ) + // if (list.isNotEmpty()) { + // handleNewAndTempMessages( + // receivedChatMessages = list, + // lookIntoFuture = false, + // showUnreadMessagesMarker = false + // ) + // } + // + // // this call could be deleted when we have a worker to send messages.. + // sendUnsentChatMessages(credentials, urlForChatting) + // + // // delay is a dirty workaround to make sure messages are added to adapter on initial load before dealing + // // with them (otherwise there is a race condition). + // delay(DELAY_TO_ENSURE_MESSAGES_ARE_ADDED) + // + // updateUiForLastCommonRead() + // updateUiForLastReadMessage(newestMessageIdFromDb) + // } + // } + private suspend fun getCappedMessagesAmountOfChatBlock(messageId: Long): Int { val chatBlock = getBlockOfMessage(messageId.toInt()) @@ -268,196 +279,198 @@ class OfflineFirstChatRepository @Inject constructor( } } - private fun updateUiForLastCommonRead() { - scope.launch { - newXChatLastCommonRead?.let { - _lastCommonReadFlow.emit(it) - } + private suspend fun updateUiForLastCommonRead() { + newXChatLastCommonRead?.let { + _lastCommonReadFlow.emit(it) } } - override fun loadMoreMessages( - beforeMessageId: Long, - roomToken: String, - withMessageLimit: Int, - withNetworkParams: Bundle - ): Job = - scope.launch { - Log.d(TAG, "---- loadMoreMessages for $beforeMessageId ------------") + suspend fun initLongPolling() { + Log.d(TAG, "---- initLongPolling ------------") - val fieldMap = getFieldMap( - lookIntoFuture = false, - timeout = 0, - includeLastKnown = false, - setReadMarker = true, - lastKnown = beforeMessageId.toInt() - ) - withNetworkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap) + val initialMessageId = chatBlocksDao.getNewestMessageIdFromChatBlocks(internalConversationId, threadId) + Log.d(TAG, "initialMessageId for initLongPolling: $initialMessageId") + + var fieldMap = getFieldMap( + lookIntoFuture = true, + // timeout for first longpoll is 0, so "unread message" info is not shown if there were + // initially no messages but someone writes us in the first 30 seconds. + timeout = 0, + includeLastKnown = false, + lastKnown = initialMessageId.toInt() + ) - val loadFromServer = hasToLoadPreviousMessagesFromServer(beforeMessageId, DEFAULT_MESSAGES_LIMIT) + val networkParams = Bundle() - if (loadFromServer) { - Log.d(TAG, "Starting online request for loadMoreMessages") - sync(withNetworkParams) - } + var showUnreadMessagesMarker = true + + while (true) { + if (!networkMonitor.isOnline.value || itIsPaused) { + delay(HALF_SECOND) + } else { + // sync database with server + // (This is a long blocking call because long polling (lookIntoFuture and timeout) is set) + networkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap) + + Log.d(TAG, "Starting online request for long polling") + getAndPersistMessages(networkParams) + // if (!resultsFromSync.isNullOrEmpty()) { + // val chatMessages = resultsFromSync.map(ChatMessageEntity::asModel) + // + // val weHaveMessagesFromOurself = chatMessages.any { it.actorId == currentUser.userId } + // showUnreadMessagesMarker = showUnreadMessagesMarker && !weHaveMessagesFromOurself + // + // } else { + // Log.d(TAG, "resultsFromSync are null or empty") + // } + + // updateUiForLastCommonRead() + + // getNewestMessageIdFromChatBlocks wont work for insurance calls. we dont want newest message + // but only the newest message that came from sync (not from signaling) + // -> create new var to save newest message from sync (set for initial and long polling requests) + val newestMessage = chatBlocksDao.getNewestMessageIdFromChatBlocks( + internalConversationId, + threadId + ).toInt() + + // update field map vars for next cycle + fieldMap = getFieldMap( + lookIntoFuture = true, + timeout = 30, + includeLastKnown = false, + lastKnown = newestMessage + ) - showMessagesBefore(internalConversationId, beforeMessageId, DEFAULT_MESSAGES_LIMIT) - updateUiForLastCommonRead() + showUnreadMessagesMarker = false + } } + } - override fun initMessagePolling(initialMessageId: Long): Job = - scope.launch { - Log.d(TAG, "---- initMessagePolling ------------") + suspend fun initInsuranceRequests() { + Log.d(TAG, "---- initInsuranceRequests ------------") - Log.d(TAG, "newestMessage: $initialMessageId") + while (true) { + delay(INSURANCE_REQUEST_DELAY) + Log.d(TAG, "execute insurance request with latestKnownMessageIdFromSync: $latestKnownMessageIdFromSync") var fieldMap = getFieldMap( lookIntoFuture = true, - // timeout for first longpoll is 0, so "unread message" info is not shown if there were - // initially no messages but someone writes us in the first 30 seconds. timeout = 0, includeLastKnown = false, - setReadMarker = true, - lastKnown = initialMessageId.toInt() + lastKnown = latestKnownMessageIdFromSync.toInt(), + limit = 200 ) - val networkParams = Bundle() + networkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap) - var showUnreadMessagesMarker = true - - while (isActive) { - if (!networkMonitor.isOnline.value || itIsPaused) { - Thread.sleep(HALF_SECOND) - } else { - // sync database with server - // (This is a long blocking call because long polling (lookIntoFuture) is set) - networkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap) - - Log.d(TAG, "Starting online request for long polling") - val resultsFromSync = sync(networkParams) - if (!resultsFromSync.isNullOrEmpty()) { - val chatMessages = resultsFromSync.map(ChatMessageEntity::asModel) - - val weHaveMessagesFromOurself = chatMessages.any { it.actorId == currentUser.userId } - showUnreadMessagesMarker = showUnreadMessagesMarker && !weHaveMessagesFromOurself - - if (isActive) { - handleNewAndTempMessages( - receivedChatMessages = chatMessages, - lookIntoFuture = true, - showUnreadMessagesMarker = showUnreadMessagesMarker - ) - } else { - Log.d(TAG, "scope was already canceled") - } - } else { - Log.d(TAG, "resultsFromSync are null or empty") - } - - updateUiForLastCommonRead() - - val newestMessage = chatBlocksDao.getNewestMessageIdFromChatBlocks( - internalConversationId, - threadId - ).toInt() - - // update field map vars for next cycle - fieldMap = getFieldMap( - lookIntoFuture = true, - timeout = 30, - includeLastKnown = false, - setReadMarker = true, - lastKnown = newestMessage - ) - - showUnreadMessagesMarker = false - } - } + getAndPersistMessages(networkParams) } + } - private suspend fun handleNewAndTempMessages( - receivedChatMessages: List, - lookIntoFuture: Boolean, - showUnreadMessagesMarker: Boolean + override suspend fun loadMoreMessages( + beforeMessageId: Long, + roomToken: String, + withMessageLimit: Int, + withNetworkParams: Bundle ) { - receivedChatMessages.forEach { - Log.d(TAG, "receivedChatMessage: " + it.message) - } - - // remove all temp messages from UI - val oldTempMessages = chatDao.getTempMessagesForConversation(internalConversationId) - .first() - .map(ChatMessageEntity::asModel) - oldTempMessages.forEach { - Log.d(TAG, "oldTempMessage to be removed from UI: " + it.message) - _removeMessageFlow.emit(it) - } - - // add new messages to UI - val tripleChatMessages = Triple(lookIntoFuture, showUnreadMessagesMarker, receivedChatMessages) - _messageFlow.emit(tripleChatMessages) - - // remove temp messages from DB that are now found in the new messages - val chatMessagesReferenceIds = receivedChatMessages.mapTo(HashSet(receivedChatMessages.size)) { it.referenceId } - val tempChatMessagesThatCanBeReplaced = oldTempMessages.filter { it.referenceId in chatMessagesReferenceIds } - tempChatMessagesThatCanBeReplaced.forEach { - Log.d(TAG, "oldTempMessage that was identified in newMessages: " + it.message) - } - chatDao.deleteTempChatMessages( - internalConversationId, - tempChatMessagesThatCanBeReplaced.map { it.referenceId!! } + Log.d(TAG, "---- loadMoreMessages for $beforeMessageId ------------") + + val fieldMap = getFieldMap( + lookIntoFuture = false, + timeout = 0, + includeLastKnown = false, + lastKnown = beforeMessageId.toInt(), + limit = withMessageLimit ) + withNetworkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap) - // add the remaining temp messages to UI again - val remainingTempMessages = chatDao.getTempMessagesForConversation(internalConversationId) - .first() - .sortedBy { it.internalId } - .map(ChatMessageEntity::asModel) - - remainingTempMessages.forEach { - Log.d(TAG, "remainingTempMessage: " + it.message) - } - - val triple = Triple(true, false, remainingTempMessages) - _messageFlow.emit(triple) + Log.d(TAG, "Starting online request for loadMoreMessages") + getAndPersistMessages(withNetworkParams) } - private suspend fun hasToLoadPreviousMessagesFromServer(beforeMessageId: Long, amountToCheck: Int): Boolean { - val loadFromServer: Boolean - - val blockForMessage = getBlockOfMessage(beforeMessageId.toInt()) - - if (blockForMessage == null) { - Log.d(TAG, "No blocks for this message were found so we have to ask server") - loadFromServer = true - } else if (!blockForMessage.hasHistory) { - Log.d(TAG, "The last chatBlock is reached so we won't request server for older messages") - loadFromServer = false - } else { - val amountBetween = chatDao.getCountBetweenMessageIds( - internalConversationId, - beforeMessageId, - blockForMessage.oldestMessageId, - threadId - ) - loadFromServer = amountBetween < amountToCheck - - Log.d( - TAG, - "Amount between messageId " + beforeMessageId + " and " + blockForMessage.oldestMessageId + - " is: " + amountBetween + " and $amountToCheck were needed, so 'loadFromServer' is " + - loadFromServer - ) - } - return loadFromServer - } + // private suspend fun handleNewAndTempMessages( + // receivedChatMessages: List, + // lookIntoFuture: Boolean, + // showUnreadMessagesMarker: Boolean + // ) { + // receivedChatMessages.forEach { + // Log.d(TAG, "receivedChatMessage: " + it.message) + // } + // + // // remove all temp messages from UI + // val oldTempMessages = chatDao.getTempMessagesForConversation(internalConversationId) + // .first() + // .map(ChatMessageEntity::asModel) + // oldTempMessages.forEach { + // Log.d(TAG, "oldTempMessage to be removed from UI: " + it.message) + // _removeMessageFlow.emit(it) + // } + // + // // add new messages to UI + // val tripleChatMessages = Triple(lookIntoFuture, showUnreadMessagesMarker, receivedChatMessages) + // _messageFlow.emit(tripleChatMessages) + // + // // remove temp messages from DB that are now found in the new messages + // val chatMessagesReferenceIds = receivedChatMessages.mapTo(HashSet(receivedChatMessages.size)) { it.referenceId } + // val tempChatMessagesThatCanBeReplaced = oldTempMessages.filter { it.referenceId in chatMessagesReferenceIds } + // tempChatMessagesThatCanBeReplaced.forEach { + // Log.d(TAG, "oldTempMessage that was identified in newMessages: " + it.message) + // } + // chatDao.deleteTempChatMessages( + // internalConversationId, + // tempChatMessagesThatCanBeReplaced.map { it.referenceId!! } + // ) + // + // // add the remaining temp messages to UI again + // val remainingTempMessages = chatDao.getTempMessagesForConversation(internalConversationId) + // .first() + // .sortedBy { it.internalId } + // .map(ChatMessageEntity::asModel) + // + // remainingTempMessages.forEach { + // Log.d(TAG, "remainingTempMessage: " + it.message) + // } + // + // val triple = Triple(true, false, remainingTempMessages) + // _messageFlow.emit(triple) + // } + + // private suspend fun hasToLoadPreviousMessagesFromServer(beforeMessageId: Long, amountToCheck: Int): Boolean { + // val loadFromServer: Boolean + // + // val blockForMessage = getBlockOfMessage(beforeMessageId.toInt()) + // + // if (blockForMessage == null) { + // Log.d(TAG, "No blocks for this message were found so we have to ask server") + // loadFromServer = true + // } else if (!blockForMessage.hasHistory) { + // Log.d(TAG, "The last chatBlock is reached so we won't request server for older messages") + // loadFromServer = false + // } else { + // val amountBetween = chatDao.getCountBetweenMessageIds( + // internalConversationId, + // beforeMessageId, + // blockForMessage.oldestMessageId, + // threadId + // ) + // loadFromServer = amountBetween < amountToCheck + // + // Log.d( + // TAG, + // "Amount between messageId " + beforeMessageId + " and " + blockForMessage.oldestMessageId + + // " is: " + amountBetween + " and $amountToCheck were needed, so 'loadFromServer' is " + + // loadFromServer + // ) + // } + // return loadFromServer + // } @Suppress("LongParameterList") private fun getFieldMap( lookIntoFuture: Boolean, timeout: Int, includeLastKnown: Boolean, - setReadMarker: Boolean, lastKnown: Int?, limit: Int = DEFAULT_MESSAGES_LIMIT ): HashMap { @@ -479,7 +492,7 @@ class OfflineFirstChatRepository @Inject constructor( fieldMap["limit"] = limit fieldMap["lookIntoFuture"] = if (lookIntoFuture) 1 else 0 - fieldMap["setReadMarker"] = if (setReadMarker) 1 else 0 + fieldMap["setReadMarker"] = 0 return fieldMap } @@ -487,154 +500,190 @@ class OfflineFirstChatRepository @Inject constructor( override suspend fun getNumberOfThreadReplies(threadId: Long): Int = chatDao.getNumberOfThreadReplies(internalConversationId, threadId) - override suspend fun getMessage(messageId: Long, bundle: Bundle): Flow { - Log.d(TAG, "Get message with id $messageId") - val loadFromServer = hasToLoadPreviousMessagesFromServer(messageId, 1) + // override suspend fun getMessage(messageId: Long, bundle: Bundle): Flow { + // Log.d(TAG, "Get message with id $messageId") + // + // val localMessage = chatDao.getChatMessageOnce( + // internalConversationId, + // messageId + // ) + // + // if (localMessage == null) { + // val fieldMap = getFieldMap( + // lookIntoFuture = false, + // timeout = 0, + // includeLastKnown = true, + // lastKnown = messageId.toInt(), + // limit = 1 + // ) + // bundle.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap) + // + // Log.d(TAG, "Starting online request for single message") + // getAndPersistMessages(bundle) + // } + // + // return chatDao + // .getChatMessageForConversationNullable(internalConversationId, messageId) + // .mapNotNull { it?.toDomainModel() } + // .take(1) + // .timeout(5_000.microseconds) + // .catch { /* timeout -> emit nothing */ } + // } + + override fun getMessage( + messageId: Long, + bundle: Bundle + ): Flow = flow { - if (loadFromServer) { - val fieldMap = getFieldMap( - lookIntoFuture = false, - timeout = 0, - includeLastKnown = true, - setReadMarker = false, - lastKnown = messageId.toInt(), - limit = 1 - ) - bundle.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap) + val local = chatDao.getChatMessageEntity(internalConversationId, messageId) - Log.d(TAG, "Starting online request for single message (e.g. a reply)") - sync(bundle) + if (local != null) { + emit(local.toDomainModel()) + return@flow } - return chatDao.getChatMessageForConversation( - internalConversationId, - messageId - ).map(ChatMessageEntity::asModel) + + getAndPersistMessages(bundle) + + emitAll( + observeMessageNonNull(internalConversationId, messageId) + .map { it.toDomainModel() } + .take(1) + ) } + fun observeMessageNonNull( + internalConversationId: String, + messageId: Long + ): Flow = + chatDao.observeMessage(internalConversationId, messageId) + .filterNotNull() + override suspend fun getParentMessageById(messageId: Long): Flow = chatDao.getChatMessageForConversation( internalConversationId, messageId - ).map(ChatMessageEntity::asModel) - - @Suppress("UNCHECKED_CAST", "MagicNumber", "Detekt.TooGenericExceptionCaught") - private fun getMessagesFromServer(bundle: Bundle): Pair>? { - val fieldMap = bundle.getSerializable(BundleKeys.KEY_FIELD_MAP) as HashMap - - var attempts = 1 - while (attempts < 5) { - Log.d(TAG, "message limit: " + fieldMap["limit"]) - try { - val result = network.pullChatMessages(credentials, urlForChatting, fieldMap) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .map { it -> - when (it.code()) { - HTTP_CODE_OK -> { - Log.d(TAG, "getMessagesFromServer HTTP_CODE_OK") - newXChatLastCommonRead = it.headers()["X-Chat-Last-Common-Read"]?.let { - Integer.parseInt(it) - } - - return@map Pair( - HTTP_CODE_OK, - (it.body() as ChatOverall).ocs!!.data!! - ) - } - - HTTP_CODE_NOT_MODIFIED -> { - Log.d(TAG, "getMessagesFromServer HTTP_CODE_NOT_MODIFIED") - - return@map Pair( - HTTP_CODE_NOT_MODIFIED, - listOf() - ) - } + ).map(ChatMessageEntity::toDomainModel) - HTTP_CODE_PRECONDITION_FAILED -> { - Log.d(TAG, "getMessagesFromServer HTTP_CODE_PRECONDITION_FAILED") + override suspend fun fetchMissingParents( + conversationId: String, + parentIds: List + ) { + // TODO fetch parent messages from server + // val newIds = parentIds + // .filterNot { it in requestedParentIds } + // + // if (newIds.isEmpty()) return + // + // requestedParentIds.addAll(newIds) + // + // try { + // val response = api.getMessagesByIds(newIds) + // + // val entities = response.map { + // it.toEntity(conversationId) + // } + // + // chatDao.insertMessages(entities) + // + // } catch (e: Exception) { + // // log if needed + // } + } - return@map Pair( - HTTP_CODE_PRECONDITION_FAILED, - listOf() - ) - } + fun pullMessagesFlow(bundle: Bundle): Flow = + flow { + val fieldMap = bundle.getSerializable(BundleKeys.KEY_FIELD_MAP) as HashMap + var attempts = 1 + + while (attempts < 5) { + runCatching { + network.pullChatMessages(credentials, urlForChatting, fieldMap) + }.fold( + onSuccess = { response -> + val result = when (response.code()) { + HTTP_CODE_OK -> ChatPullResult.Success( + messages = response.body()?.ocs?.data.orEmpty(), + lastCommonRead = response.headers()["X-Chat-Last-Common-Read"]?.toInt() + ) + HTTP_CODE_NOT_MODIFIED -> ChatPullResult.NotModified + HTTP_CODE_PRECONDITION_FAILED -> ChatPullResult.PreconditionFailed + else -> ChatPullResult.Error(HttpException(response)) + } - else -> { - return@map Pair( - HTTP_CODE_PRECONDITION_FAILED, - listOf() - ) - } + emit(result) + return@flow + }, + onFailure = { e -> + Log.e(TAG, "Attempt $attempts failed", e) + attempts++ + fieldMap["limit"] = when (attempts) { + 2 -> 50 + 3 -> 10 + else -> 5 } } - .blockingSingle() - return result - } catch (e: Exception) { - Log.e(TAG, "Something went wrong when pulling chat messages (attempt: $attempts)", e) - attempts++ - - val newMessageLimit = when (attempts) { - 2 -> 50 - 3 -> 10 - else -> 5 - } - fieldMap["limit"] = newMessageLimit + ) } - } - Log.e(TAG, "All attempts to get messages from server failed") - return null - } - private suspend fun sync(bundle: Bundle): List? { + emit(ChatPullResult.Error(IllegalStateException("All attempts failed"))) + }.flowOn(Dispatchers.IO) + + private suspend fun getAndPersistMessages(bundle: Bundle) { if (!networkMonitor.isOnline.value) { Log.d(TAG, "Device is offline, can't load chat messages from server") - return null } - val result = getMessagesFromServer(bundle) - if (result == null) { - Log.d(TAG, "No result from server") - return null - } - - var chatMessagesFromSync: List? = null - val fieldMap = bundle.getSerializable(BundleKeys.KEY_FIELD_MAP) as HashMap val queriedMessageId = fieldMap["lastKnownMessageId"] val lookIntoFuture = fieldMap["lookIntoFuture"] == 1 - val statusCode = result.first + val result = pullMessagesFlow(bundle).first() - val hasHistory = getHasHistory(statusCode, lookIntoFuture) + when (result) { + is ChatPullResult.Success -> { + newXChatLastCommonRead = result.lastCommonRead + updateUiForLastCommonRead() - Log.d( - TAG, - "internalConv=$internalConversationId statusCode=$statusCode lookIntoFuture=$lookIntoFuture " + - "hasHistory=$hasHistory " + - "queriedMessageId=$queriedMessageId" - ) + val hasHistory = getHasHistory(HTTP_CODE_OK, lookIntoFuture) - val blockContainingQueriedMessage: ChatBlockEntity? = getBlockOfMessage(queriedMessageId) + Log.d( + TAG, + "internalConv=$internalConversationId statusCode=${HTTP_CODE_OK} lookIntoFuture=$lookIntoFuture " + + "hasHistory=$hasHistory queriedMessageId=$queriedMessageId" + ) - if (blockContainingQueriedMessage != null && !hasHistory) { - blockContainingQueriedMessage.hasHistory = false - chatBlocksDao.upsertChatBlock(blockContainingQueriedMessage) - Log.d(TAG, "End of chat was reached so hasHistory=false is set") - } + val blockContainingQueriedMessage: ChatBlockEntity? = getBlockOfMessage(queriedMessageId) - if (result.second.isNotEmpty()) { - chatMessagesFromSync = updateMessagesData( - result.second, - blockContainingQueriedMessage, - lookIntoFuture, - hasHistory - ) - } else { - Log.d(TAG, "no data is updated...") - } + blockContainingQueriedMessage?.takeIf { !hasHistory }?.apply { + this.hasHistory = false + chatBlocksDao.upsertChatBlock(this) + Log.d(TAG, "End of chat reached, set hasHistory=false") + } + + if (result.messages.isNotEmpty()) { + updateMessagesData( + result.messages, + blockContainingQueriedMessage, + lookIntoFuture, + hasHistory + ) + } else { + Log.d(TAG, "No new messages to update") + } + } + + is ChatPullResult.NotModified -> { + Log.d(TAG, "Server returned NOT_MODIFIED, nothing to update") + } + + is ChatPullResult.PreconditionFailed -> { + Log.d(TAG, "Server returned PRECONDITION_FAILED, nothing to update") + } - return chatMessagesFromSync + is ChatPullResult.Error -> { + Log.e(TAG, "Error pulling messages from server", result.throwable) + } + } } private suspend fun OfflineFirstChatRepository.updateMessagesData( @@ -642,20 +691,16 @@ class OfflineFirstChatRepository @Inject constructor( blockContainingQueriedMessage: ChatBlockEntity?, lookIntoFuture: Boolean, hasHistory: Boolean - ): List { - handleUpdateMessages(chatMessagesJson) - - val chatMessagesFromSyncToProcess = chatMessagesJson.map { - it.asEntity(currentUser.id!!) - } - - chatDao.upsertChatMessages(chatMessagesFromSyncToProcess) + ) { + val chatMessageEntities = persistChatMessagesAndHandleSystemMessages(chatMessagesJson) - val oldestIdFromSync = chatMessagesFromSyncToProcess.minByOrNull { it.id }!!.id - val newestIdFromSync = chatMessagesFromSyncToProcess.maxByOrNull { it.id }!!.id + val oldestIdFromSync = chatMessageEntities.minByOrNull { it.id }!!.id + val newestIdFromSync = chatMessageEntities.maxByOrNull { it.id }!!.id Log.d(TAG, "oldestIdFromSync: $oldestIdFromSync") Log.d(TAG, "newestIdFromSync: $newestIdFromSync") + latestKnownMessageIdFromSync = maxOf(latestKnownMessageIdFromSync, newestIdFromSync) + var oldestMessageIdForNewChatBlock = oldestIdFromSync var newestMessageIdForNewChatBlock = newestIdFromSync @@ -683,13 +728,11 @@ class OfflineFirstChatRepository @Inject constructor( newestMessageId = newestMessageIdForNewChatBlock, hasHistory = hasHistory ) - chatBlocksDao.upsertChatBlock(newChatBlock) // crash when no conversation thread exists! - + chatBlocksDao.upsertChatBlock(newChatBlock) updateBlocks(newChatBlock) - return chatMessagesFromSyncToProcess } - private suspend fun handleUpdateMessages(messagesJson: List) { + private suspend fun handleSystemMessagesThatAffectDatabase(messagesJson: List) { messagesJson.forEach { messageJson -> when (messageJson.systemMessageType) { ChatMessage.SystemMessageType.REACTION, @@ -712,7 +755,6 @@ class OfflineFirstChatRepository @Inject constructor( } chatDao.upsertChatMessage(parentMessageEntity) - _updateMessageFlow.emit(parentMessageEntity.asModel()) } } } @@ -768,7 +810,7 @@ class OfflineFirstChatRepository @Inject constructor( return blockContainingQueriedMessage } - private suspend fun updateBlocks(chatBlock: ChatBlockEntity): ChatBlockEntity? { + private suspend fun updateBlocks(chatBlock: ChatBlockEntity) { val connectedChatBlocks = chatBlocksDao.getConnectedChatBlocks( internalConversationId = internalConversationId, @@ -777,12 +819,11 @@ class OfflineFirstChatRepository @Inject constructor( newestMessageId = chatBlock.newestMessageId ).first() - return if (connectedChatBlocks.size == 1) { + if (connectedChatBlocks.size == 1) { Log.d(TAG, "This chatBlock is not connected to others") val chatBlockFromDb = connectedChatBlocks[0] Log.d(TAG, "chatBlockFromDb.oldestMessageId: " + chatBlockFromDb.oldestMessageId) Log.d(TAG, "chatBlockFromDb.newestMessageId: " + chatBlockFromDb.newestMessageId) - chatBlockFromDb } else if (connectedChatBlocks.size > 1) { Log.d(TAG, "Found " + connectedChatBlocks.size + " chat blocks that are connected") val oldestIdFromDbChatBlocks = @@ -810,10 +851,8 @@ class OfflineFirstChatRepository @Inject constructor( Log.d(TAG, "A new chat block was created that covers all the range of the found chatblocks") Log.d(TAG, "new chatBlock - oldest MessageId: $oldestIdFromDbChatBlocks") Log.d(TAG, "new chatBlock - newest MessageId: $newestIdFromDbChatBlocks") - newChatBlock } else { Log.d(TAG, "No chat block found ....") - null } } @@ -828,7 +867,7 @@ class OfflineFirstChatRepository @Inject constructor( messageLimit, threadId ).map { - it.map(ChatMessageEntity::asModel) + it.map(ChatMessageEntity::toDomainModel) }.first() private suspend fun showMessagesBefore(internalConversationId: String, messageId: Long, limit: Int) { @@ -843,7 +882,7 @@ class OfflineFirstChatRepository @Inject constructor( messageLimit, threadId ).map { - it.map(ChatMessageEntity::asModel) + it.map(ChatMessageEntity::toDomainModel) }.first() val list = getMessagesBefore( @@ -860,9 +899,6 @@ class OfflineFirstChatRepository @Inject constructor( override fun handleOnPause() { itIsPaused = true - if (this::scope.isInitialized) { - scope.cancel() - } } override fun handleOnResume() { @@ -902,7 +938,7 @@ class OfflineFirstChatRepository @Inject constructor( threadTitle ) - val chatMessageModel = response.ocs?.data?.asModel() + val chatMessageModel = response.ocs?.data?.toDomainModel() val sentMessage = if (this@OfflineFirstChatRepository::internalConversationId.isInitialized) { chatDao @@ -939,7 +975,7 @@ class OfflineFirstChatRepository @Inject constructor( it.sendStatus = SendStatus.FAILED chatDao.updateChatMessage(it) - val failedMessageModel = it.asModel() + val failedMessageModel = it.toDomainModel() _updateMessageFlow.emit(failedMessageModel) } emit(Result.failure(e)) @@ -965,7 +1001,7 @@ class OfflineFirstChatRepository @Inject constructor( messageToResend.sendStatus = SendStatus.PENDING chatDao.updateChatMessage(messageToResend) - val messageToResendModel = messageToResend.asModel() + val messageToResendModel = messageToResend.toDomainModel() _updateMessageFlow.emit(messageToResendModel) sendChatMessage( @@ -1005,12 +1041,12 @@ class OfflineFirstChatRepository @Inject constructor( chatDao.upsertChatMessage(tempChatMessageEntity) - val tempChatMessageModel = tempChatMessageEntity.asModel() - - emit(Result.success(tempChatMessageModel)) - - val triple = Triple(true, false, listOf(tempChatMessageModel)) - _messageFlow.emit(triple) + // val tempChatMessageModel = tempChatMessageEntity.asModel() + // + // emit(Result.success(tempChatMessageModel)) + // + // val triple = Triple(true, false, listOf(tempChatMessageModel)) + // _messageFlow.emit(triple) } catch (e: Exception) { Log.e(TAG, "Something went wrong when adding temporary message", e) emit(Result.failure(e)) @@ -1047,7 +1083,7 @@ class OfflineFirstChatRepository @Inject constructor( messageToEdit.message = editedMessageText chatDao.upsertChatMessage(messageToEdit) - val editedMessageModel = messageToEdit.asModel() + val editedMessageModel = messageToEdit.toDomainModel() _updateMessageFlow.emit(editedMessageModel) emit(true) } catch (e: Exception) { @@ -1079,14 +1115,13 @@ class OfflineFirstChatRepository @Inject constructor( override suspend fun deleteTempMessage(chatMessage: ChatMessage) { chatDao.deleteTempChatMessages(internalConversationId, listOf(chatMessage.referenceId.orEmpty())) - _removeMessageFlow.emit(chatMessage) } override suspend fun pinMessage(credentials: String, url: String, pinUntil: Int): Flow = flow { runCatching { val overall = network.pinMessage(credentials, url, pinUntil) - emit(overall.ocs?.data?.asModel()) + emit(overall.ocs?.data?.toDomainModel()) }.getOrElse { throwable -> Log.e(TAG, "Error in pinMessage: $throwable") } @@ -1096,7 +1131,7 @@ class OfflineFirstChatRepository @Inject constructor( flow { runCatching { val overall = network.unPinMessage(credentials, url) - emit(overall.ocs?.data?.asModel()) + emit(overall.ocs?.data?.toDomainModel()) }.getOrElse { throwable -> Log.e(TAG, "Error in unPinMessage: $throwable") } @@ -1112,6 +1147,52 @@ class OfflineFirstChatRepository @Inject constructor( } } + override suspend fun onSignalingChatMessageReceived(chatMessage: ChatMessageJson) { + persistChatMessagesAndHandleSystemMessages(listOf(chatMessage)) + + // we assume that the signaling message is on top of the latest chatblock and include it inside it. + // If for whatever reason the assume was not correct and there would be messages in between, the + // insurance request should fix this by adding the missing messages and updating the chatblocks. + val latestChatBlock = chatBlocksDao.getLatestChatBlock(internalConversationId, threadId) + latestChatBlock.first()?.apply { + newestMessageId = chatMessage.id + chatBlocksDao.upsertChatBlock(this) + } + } + + suspend fun persistChatMessagesAndHandleSystemMessages( + chatMessages: List + ): List { + handleSystemMessagesThatAffectDatabase(chatMessages) + + val chatMessageEntities = chatMessages.map { + it.asEntity(currentUser.id!!) + } + + // This may overwrite message with the same referenceId, which is expected (temp messages will be overwritten + // by received ones) + chatDao.upsertChatMessages(chatMessageEntities) + + return chatMessageEntities + } + + override fun observeMessages(internalConversationId: String): Flow> = + chatBlocksDao + .getLatestChatBlock(internalConversationId, threadId) + .distinctUntilChanged() + .flatMapLatest { latestBlock -> + + if (latestBlock == null) { + flowOf(emptyList()) + } else { + chatDao.getMessagesNewerThan( + internalConversationId = internalConversationId, + threadId = threadId, + oldestMessageId = latestBlock.oldestMessageId + ) + } + } + @Suppress("LongParameterList") override suspend fun sendScheduledChatMessage( credentials: String, @@ -1159,7 +1240,7 @@ class OfflineFirstChatRepository @Inject constructor( val messageJson = response.ocs?.data ?: error("updateScheduledMessage: response.ocs?.data is null") - val updatedMessage = messageJson.asModel().copy( + val updatedMessage = messageJson.toDomainModel().copy( token = messageJson.id.toString() ) @@ -1182,7 +1263,7 @@ class OfflineFirstChatRepository @Inject constructor( flow { val response = network.getScheduledMessages(credentials, url) val messages = response.ocs?.data.orEmpty().map { messageJson -> - val jsonToModel = messageJson.asModel() + val jsonToModel = messageJson.toDomainModel() jsonToModel.copy( token = messageJson.id.toString() ) @@ -1246,6 +1327,7 @@ class OfflineFirstChatRepository @Inject constructor( private const val HALF_SECOND = 500L private const val DELAY_TO_ENSURE_MESSAGES_ARE_ADDED: Long = 100 private const val DEFAULT_MESSAGES_LIMIT = 100 - private const val MILLIES = 1000 + private const val MILLIES = 1000L + private const val INSURANCE_REQUEST_DELAY = 2 * 60 * MILLIES } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt index ef42ec91e47..07a694cd5b3 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt @@ -12,6 +12,7 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.capabilities.SpreedCapability import com.nextcloud.talk.models.json.chat.ChatMessageJson +import com.nextcloud.talk.models.json.chat.ChatOverall import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.generic.GenericOverall @@ -22,7 +23,6 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.message.SendMessageUtils import io.reactivex.Observable import retrofit2.Response -import com.nextcloud.talk.models.json.chat.ChatOverall class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines: NcApiCoroutines) : ChatNetworkDataSource { @@ -159,11 +159,11 @@ class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines: threadTitle ) - override fun pullChatMessages( + override suspend fun pullChatMessages( credentials: String, url: String, fieldMap: HashMap - ): Observable> = ncApi.pullChatMessages(credentials, url, fieldMap).map { it } + ): Response = ncApiCoroutines.pullChatMessages(credentials, url, fieldMap) override fun deleteChatMessage(credentials: String, url: String): Observable = ncApi.deleteChatMessage(credentials, url).map { diff --git a/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt b/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt new file mode 100644 index 00000000000..cb7a8ce0624 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.domain + +import com.nextcloud.talk.models.json.chat.ChatMessageJson + +sealed class ChatPullResult { + data class Success(val messages: List, val lastCommonRead: Int?) : ChatPullResult() + + object NotModified : ChatPullResult() + object PreconditionFailed : ChatPullResult() + data class Error(val throwable: Throwable) : ChatPullResult() +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt new file mode 100644 index 00000000000..56de3aaf0f8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt @@ -0,0 +1,214 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.ui.model + +import android.text.TextUtils +import androidx.compose.runtime.Immutable +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.database.model.SendStatus +import com.nextcloud.talk.utils.DrawableUtils +import java.time.LocalDate + +// immutable class for chat message UI. only val, no vars! +@Immutable +data class ChatMessageUi( + val id: Int, + val text: String, + val message: String, // what is the difference between message and text? remove one? + val renderMarkdown: Boolean, + val actorDisplayName: String, + val isThread: Boolean, + val threadTitle: String, + val incoming: Boolean, + val isDeleted: Boolean, + val avatarUrl: String?, + val statusIcon: MessageStatusIcon, + val timestamp: Long, + val date: LocalDate, + val content: MessageTypeContent?, + val parentMessage: ChatMessageUi? = null +) + +sealed interface MessageTypeContent { + object RegularText : MessageTypeContent + object SystemMessage : MessageTypeContent + + data class LinkPreview( + // TODO + val todo: String + ) : MessageTypeContent + + data class Image(val imageUrl: String, val drawableResourceId: Int) : MessageTypeContent + + data class Geolocation(val lat: Double, val lon: Double) : MessageTypeContent + + data class Poll(val pollId: String, val pollName: String) : MessageTypeContent + + data class Deck(val cardName: String, val stackName: String, val boardName: String, val cardLink: String) : + MessageTypeContent + + data class Voice( + // TODO + val todo: String + ) : MessageTypeContent +} + +enum class MessageStatusIcon { + FAILED, + SENDING, + READ, + SENT +} + +// Domain model (ChatMessage) to UI model (ChatMessageUi) +fun ChatMessage.toUiModel( + chatMessage: ChatMessage, + lastCommonReadMessageId: Int, + parentMessage: ChatMessage? +): ChatMessageUi = + ChatMessageUi( + id = jsonMessageId, + text = text, + message = message.orEmpty(), // what is the difference between message and text? remove one? + renderMarkdown = renderMarkdown == true, + actorDisplayName = actorDisplayName.orEmpty(), + threadTitle = threadTitle.orEmpty(), + isThread = isThread, + incoming = incoming, + isDeleted = isDeleted, + avatarUrl = avatarUrl, + statusIcon = resolveStatusIcon( + jsonMessageId, + lastCommonReadMessageId, + isTemporary, + sendStatus + ), + timestamp = timestamp, + date = dateKey(), + content = getMessageTypeContent(chatMessage), + parentMessage = parentMessage?.toUiModel(parentMessage, 0, null) + ) + +fun resolveStatusIcon( + jsonMessageId: Int, + lastCommonReadMessageId: Int, + isTemporary: Boolean, + sendStatus: SendStatus? +): MessageStatusIcon { + val status = if (sendStatus == SendStatus.FAILED) { + MessageStatusIcon.FAILED + } else if (isTemporary) { + MessageStatusIcon.SENDING + } else if (jsonMessageId <= lastCommonReadMessageId) { + MessageStatusIcon.READ + } else { + MessageStatusIcon.SENT + } + return status +} + +fun getMessageTypeContent(message: ChatMessage): MessageTypeContent? = + if (!TextUtils.isEmpty(message.systemMessage)) { + MessageTypeContent.SystemMessage + } else if (message.isVoiceMessage) { + getVoiceContent(message) + } else if (message.hasFileAttachment()) { + getImageContent(message) + } else if (message.hasGeoLocation()) { + getGeolocationContent(message) + } else if (message.isLinkPreview()) { + getLinkPreviewContent(message) + } else if (message.isPoll()) { + getPollContent(message) + } else if (message.isDeckCard()) { + getDeckContent(message) + } else { + MessageTypeContent.RegularText + } + +fun getLinkPreviewContent(message: ChatMessage): MessageTypeContent.LinkPreview = + MessageTypeContent.LinkPreview( + todo = "still todo..." + ) + +fun getImageContent(message: ChatMessage): MessageTypeContent.Image { + val imageUri = message.imageUrl + val mimetype = message.selectedIndividualHashMap!!["mimetype"] + val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(mimetype) + + return MessageTypeContent.Image( + imageUri!!, + drawableResourceId + ) +} + +fun getGeolocationContent(message: ChatMessage): MessageTypeContent.Geolocation? { + if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { + for (key in message.messageParameters!!.keys) { + val individualHashMap: Map = message.messageParameters!![key]!! + if (individualHashMap["type"] == "geo-location") { + val lat = individualHashMap["latitude"] + val lng = individualHashMap["longitude"] + + if (lat != null && lng != null) { + val latitude = lat.toDouble() + val longitude = lng.toDouble() + return MessageTypeContent.Geolocation( + lat = latitude, + lon = longitude + ) + } + } + } + } + return null +} + +fun getPollContent(message: ChatMessage): MessageTypeContent.Poll? { + if (message.messageParameters != null && message.messageParameters!!.size > 0) { + for (key in message.messageParameters!!.keys) { + val individualHashMap: Map = message.messageParameters!![key]!! + if (individualHashMap["type"] == "talk-poll") { + val pollId = individualHashMap["id"] + val pollName = individualHashMap["name"].toString() + return MessageTypeContent.Poll( + pollId = pollId!!, + pollName = pollName + ) + } + } + } + return null +} + +fun getDeckContent(message: ChatMessage): MessageTypeContent.Deck? { + if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { + for (key in message.messageParameters!!.keys) { + val individualHashMap: Map = message.messageParameters!![key]!! + if (individualHashMap["type"] == "deck-card") { + val cardName = individualHashMap["name"] + val stackName = individualHashMap["stackname"] + val boardName = individualHashMap["boardname"] + val cardLink = individualHashMap["link"] + + return MessageTypeContent.Deck( + cardName = cardName!!, + stackName = stackName!!, + boardName = boardName!!, + cardLink = cardLink!! + ) + } + } + } + return null +} + +fun getVoiceContent(message: ChatMessage): MessageTypeContent.Voice = + MessageTypeContent.Voice( + todo = "still todo..." + ) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 41c0dd1b570..52a855a6f94 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -25,9 +25,13 @@ import com.nextcloud.talk.chat.data.io.MediaPlayerManager import com.nextcloud.talk.chat.data.io.MediaRecorderManager import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.toUiModel import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository +import com.nextcloud.talk.conversationlist.data.network.OfflineFirstConversationsRepository import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel.Companion.FOLLOWED_THREADS_EXIST -import com.nextcloud.talk.data.database.mappers.asModel +import com.nextcloud.talk.data.database.mappers.toDomainModel +import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.toIntOrZero import com.nextcloud.talk.jobs.UploadAndShareFilesWorker @@ -36,7 +40,9 @@ import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.ReactionAddedModel import com.nextcloud.talk.models.domain.ReactionDeletedModel import com.nextcloud.talk.models.json.capabilities.SpreedCapability +import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage +import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.opengraph.Reference @@ -50,7 +56,12 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.bundle.BundleKeys +import com.nextcloud.talk.utils.database.user.CurrentUserProvider import com.nextcloud.talk.utils.preferences.AppPreferences +import com.nextcloud.talk.webrtc.WebSocketConnectionHelper +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -59,18 +70,32 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.io.File +import java.time.LocalDate import javax.inject.Inject -@Suppress("TooManyFunctions", "LongParameterList", "LargeClass") -class ChatViewModel @Inject constructor( +@Suppress("TooManyFunctions", "LongParameterList") +class ChatViewModel @AssistedInject constructor( // should be removed here. Use it via RetrofitChatNetwork private val appPreferences: AppPreferences, private val chatNetworkDataSource: ChatNetworkDataSource, @@ -79,7 +104,10 @@ class ChatViewModel @Inject constructor( private val conversationRepository: OfflineConversationsRepository, private val reactionsRepository: ReactionsRepository, private val mediaRecorderManager: MediaRecorderManager, - private val audioFocusRequestManager: AudioFocusRequestManager + private val audioFocusRequestManager: AudioFocusRequestManager, + private val currentUserProvider: CurrentUserProvider, + @Assisted private val chatRoomToken: String, + @Assisted private val conversationThreadId: Long? ) : ViewModel(), DefaultLifecycleObserver { @@ -92,14 +120,25 @@ class ChatViewModel @Inject constructor( STOPPED } + @Deprecated("use currentUserFlow") lateinit var currentUser: User + private var localLastReadMessage: Int = 0 + + private var showUnreadMessagesMarker: Boolean = true + private val mediaPlayerManager: MediaPlayerManager = MediaPlayerManager.sharedInstance(appPreferences) lateinit var currentLifeCycleFlag: LifeCycleFlag val disposableSet = mutableSetOf() var mediaPlayerDuration = mediaPlayerManager.mediaPlayerDuration val mediaPlayerPosition = mediaPlayerManager.mediaPlayerPosition - var chatRoomToken: String = "" + + @Deprecated("chatkit...") + private val internalConversationId: Flow = + currentUserProvider.currentUserFlow.map { user -> + "${user.id}@$chatRoomToken" + } + var messageDraft: MessageDraft = MessageDraft() lateinit var participantPermissions: ParticipantPermissions @@ -133,6 +172,16 @@ class ChatViewModel @Inject constructor( mediaPlayerManager.handleOnStop() } + fun onSignalingChatMessageReceived(chatMessage: ChatMessageJson) { + viewModelScope.launch { + chatRepository.onSignalingChatMessageReceived(chatMessage) + } + } + + fun setUnreadMessagesMarker(shouldShow: Boolean) { + showUnreadMessagesMarker = shouldShow + } + val backgroundPlayUIFlow = mediaPlayerManager.backgroundPlayUIFlow val mediaPlayerSeekbarObserver: Flow @@ -179,18 +228,8 @@ class ChatViewModel @Inject constructor( get() = _getOpenGraph private val _getOpenGraph: MutableLiveData = MutableLiveData() - val getMessageFlow = chatRepository.messageFlow - .onEach { - _chatMessageViewState.value = if (_chatMessageViewState.value == ChatMessageInitialState) { - ChatMessageStartState - } else { - ChatMessageUpdateState - } - }.catch { - _chatMessageViewState.value = ChatMessageErrorState - } - - val getRemoveMessageFlow = chatRepository.removeMessageFlow + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() val getUpdateMessageFlow = chatRepository.updateMessageFlow @@ -198,15 +237,6 @@ class ChatViewModel @Inject constructor( val getLastReadMessageFlow = chatRepository.lastReadMessageFlow - val getConversationFlow = conversationRepository.conversationFlow - .onEach { - _getRoomViewState.value = GetRoomSuccessState - }.catch { - _getRoomViewState.value = GetRoomErrorState - } - - val getGeneralUIFlow = chatRepository.generalUIFlow - sealed interface ViewState object GetReminderStartState : ViewState @@ -218,17 +248,12 @@ class ChatViewModel @Inject constructor( val getReminderExistState: LiveData get() = _getReminderExistState - object GetRoomStartState : ViewState - object GetRoomErrorState : ViewState - object GetRoomSuccessState : ViewState - - private val _getRoomViewState: MutableLiveData = MutableLiveData(GetRoomStartState) - val getRoomViewState: LiveData - get() = _getRoomViewState - object GetCapabilitiesStartState : ViewState object GetCapabilitiesErrorState : ViewState - open class GetCapabilitiesInitialLoadState(val spreedCapabilities: SpreedCapability) : ViewState + open class GetCapabilitiesInitialLoadState( + val spreedCapabilities: SpreedCapability, + val conversationModel: ConversationModel + ) : ViewState open class GetCapabilitiesUpdateState(val spreedCapabilities: SpreedCapability) : ViewState private val _getCapabilitiesViewState: MutableLiveData = MutableLiveData(GetCapabilitiesStartState) @@ -301,25 +326,374 @@ class ChatViewModel @Inject constructor( val reactionDeletedViewState: LiveData get() = _reactionDeletedViewState - fun initData(user: User, credentials: String, urlForChatting: String, roomToken: String, threadId: Long?) { + private var firstUnreadMessageId: Int? = null + + private var oneOrMoreMessagesWereSent = false + + // ------------------------------ + // UI State. This should be the only UI state. Add more val here and update via copy whenever necessary. + // ------------------------------ + data class ChatUiState( + val items: List = emptyList(), + val showChatAvatars: Boolean = false, + + // Adding the whole conversation is just an intermediate solution as it is used in the activity. + // For the future, only necessary vars from conversation should be in the ui state + val conversation: ConversationModel? = null + ) + + private val _uiState = MutableStateFlow(ChatUiState()) + val uiState: StateFlow = _uiState + + // ------------------------------ + // Current user flows + // ------------------------------ + private val currentUserFlow: StateFlow = + currentUserProvider.currentUserFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val nonNullUserFlow = currentUserFlow.filterNotNull() + + private val conversationFlow: Flow = + nonNullUserFlow + .flatMapLatest { user -> + val userId = requireNotNull(user.id) + conversationRepository.observeConversation(userId, chatRoomToken) + } + .mapNotNull { result -> + when (result) { + is OfflineFirstConversationsRepository.ConversationResult.Found -> + result.conversation + + OfflineFirstConversationsRepository.ConversationResult.NotFound -> + null + } + } + .distinctUntilChangedBy { it.lastReadMessage } + .onEach { + println("Conversation changed: lastRead=${it.lastReadMessage}") + } + + private val conversationAndUserFlow = + combine(conversationFlow, nonNullUserFlow) { c, u -> c to u } + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), replay = 1) + + // ------------------------------ + // Messages + // ------------------------------ + private fun Flow>.mapToChatMessages(userId: String): Flow> = + map { entities -> + entities.map { entity -> + entity.toDomainModel().apply { + avatarUrl = getAvatarUrl(this) + incoming = actorId != userId + } + } + } + + private val messagesFlow: Flow> = + conversationAndUserFlow + .flatMapLatest { (conversation, user) -> + chatRepository + .observeMessages(conversation.internalId) + .distinctUntilChanged() + .mapToChatMessages(user.userId!!) + } + .map { messages -> + messages.let(::handleSystemMessages) + .let(::handleThreadMessages) + } + .distinctUntilChangedBy { it.map { msg -> msg.jsonMessageId } } + + // ------------------------------ + // Last read message cache + // ------------------------------ + private var lastReadMessage: Int = 0 + + // ------------------------------ + // Initialization + // ------------------------------ + init { + observeConversation() + observeMessages() + } + + // ------------------------------ + // Observe conversation + // ------------------------------ + private fun observeConversation() { + conversationFlow + .onEach { conversation -> + lastReadMessage = conversation.lastReadMessage + + _uiState.update { current -> + current.copy( + conversation = conversation, + showChatAvatars = !conversation.isOneToOneConversation() + ) + } + } + .launchIn(viewModelScope) + } + + // val lastCommonReadMessageId = getLastCommonReadFlow.first() + + // ------------------------------ + // Observe messages + // ------------------------------ + // private fun observeMessages() { + // combine(messagesFlow, getLastCommonReadFlow) { messages, lastRead -> + // messages.map { + // it.toUiModel( + // it, + // lastRead, + // getParentMessage(it.parentMessageId) + // ) + // } + // } + // .onEach { messages -> + // val items = buildChatItems(messages, lastReadMessage) + // _uiState.update { current -> + // current.copy(items = items) + // } + // } + // .launchIn(viewModelScope) + // } + + private fun observeMessages() { + combine(messagesFlow, getLastCommonReadFlow) { messages, lastRead -> + messages to lastRead + } + .onEach { (messages, lastRead) -> + + // Explicitly specify types for the map + val messageMap: Map = messages.associateBy { it.jsonMessageId.toLong() } + + // Parent IDs + val parentIds: List = messages.mapNotNull { it.parentMessageId } + val missingParentIds: List = + parentIds.filterNot { parentId -> messageMap.containsKey(parentId) } + .distinct() + + // 3. Fetch missing parents in background (non-blocking) + if (missingParentIds.isNotEmpty()) { + viewModelScope.launch { + // chatRepository.fetchMissingParents( // not yet implemented + // internalConversationId, + // missingParentIds + // ) + } + } + + // 4. Build UI models using available data + val uiMessages = messages.map { message -> + val parent: ChatMessage? = messageMap[message.parentMessageId] + + message.toUiModel( + message, + lastRead, + parent + ) + } + + // 5. Build UI items + val items = buildChatItems(uiMessages, lastRead) + + _uiState.update { current -> + current.copy(items = items) + } + } + .launchIn(viewModelScope) + } + + // ------------------------------ + // Build chat items (pure) + // ------------------------------ + private fun buildChatItems(uiMessages: List, lastReadMessage: Int): List { + var lastDate: LocalDate? = null + + return buildList { + if (firstUnreadMessageId == null) { + firstUnreadMessageId = + uiMessages.firstOrNull { + it.id > lastReadMessage + }?.id + Log.d(TAG, "reversedMessages.size = ${uiMessages.size}") + Log.d(TAG, "firstUnreadMessageId = $firstUnreadMessageId") + Log.d(TAG, "conversation.lastReadMessage = $lastReadMessage") + } + + for (uiMessage in uiMessages) { + val date = uiMessage.date + + if (date != lastDate) { + add(ChatItem.DateHeaderItem(date)) + lastDate = date + } + + if (!oneOrMoreMessagesWereSent && uiMessage.id == firstUnreadMessageId) { + add(ChatItem.UnreadMessagesMarkerItem(date)) + } + + add(ChatItem.MessageItem(uiMessage)) + } + }.asReversed() + } + + fun onMessageSent() { + oneOrMoreMessagesWereSent = true + } + + @Deprecated("use messagesFlow") + val messagesForChatKit: StateFlow> = + conversationAndUserFlow + .flatMapLatest { (conversation, user) -> + chatRepository + .observeMessages(conversation.internalId) + .mapToChatMessages(user.userId!!) + } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + fun observeConversationAndUserFirstTime() { + conversationAndUserFlow + .take(1) + .onEach { (conversation, user) -> + val credentials = + ApiUtils.getCredentials(user.username, user.token) ?: return@onEach + + val url = + ApiUtils.getUrlForChat(1, user.baseUrl, chatRoomToken) + + chatRepository.updateConversation(conversation) + + loadInitialMessages( + withCredentials = credentials, + withUrl = url, + isChatRelaySupported = isChatRelaySupported(user) + ) + + getCapabilities(user, chatRoomToken, conversation) + } + .launchIn(viewModelScope) + } + + fun isChatRelaySupported(user: User): Boolean { + val websocketInstance = WebSocketConnectionHelper.getWebSocketInstanceForUser(user) + return websocketInstance?.supportsChatRelay() == true + } + + fun observeConversationAndUserEveryTime() { + conversationAndUserFlow + .onEach { (conversation, user) -> + chatRepository.updateConversation(conversation) + + getCapabilities(user, chatRoomToken, conversation) + + advanceLocalLastReadMessageIfNeeded( + conversation.lastReadMessage + ) + } + .launchIn(viewModelScope) + } + + private fun handleSystemMessages(chatMessageList: List): List { + fun shouldRemoveMessage(currentMessage: MutableMap.MutableEntry): Boolean = + isInfoMessageAboutDeletion(currentMessage) || + isReactionsMessage(currentMessage) || + isPollVotedMessage(currentMessage) || + isEditMessage(currentMessage) || + isThreadCreatedMessage(currentMessage) + + val chatMessageMap = chatMessageList.associateBy { it.id }.toMutableMap() + val chatMessageIterator = chatMessageMap.iterator() + + while (chatMessageIterator.hasNext()) { + val currentMessage = chatMessageIterator.next() + + if (shouldRemoveMessage(currentMessage)) { + chatMessageIterator.remove() + } + } + return chatMessageMap.values.toList() + } + + private fun isInfoMessageAboutDeletion(currentMessage: MutableMap.MutableEntry): Boolean = + currentMessage.value.parentMessageId != null && + currentMessage.value.systemMessageType == ChatMessage + .SystemMessageType.MESSAGE_DELETED + + private fun isReactionsMessage(currentMessage: MutableMap.MutableEntry): Boolean = + currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION || + currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION_DELETED || + currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION_REVOKED + + private fun isThreadCreatedMessage(currentMessage: MutableMap.MutableEntry): Boolean = + currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.THREAD_CREATED + + private fun isEditMessage(currentMessage: MutableMap.MutableEntry): Boolean = + currentMessage.value.parentMessageId != null && + currentMessage.value.systemMessageType == ChatMessage + .SystemMessageType.MESSAGE_EDITED + + private fun isPollVotedMessage(currentMessage: MutableMap.MutableEntry): Boolean = + currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.POLL_VOTED + + private fun handleThreadMessages(chatMessageList: List): List { + fun isThreadChildMessage(currentMessage: MutableMap.MutableEntry): Boolean = + currentMessage.value.isThread && + currentMessage.value.threadId?.toInt() != currentMessage.value.jsonMessageId + + val chatMessageMap = chatMessageList.associateBy { it.id }.toMutableMap() + + if (conversationThreadId == null) { + val chatMessageIterator = chatMessageMap.iterator() + while (chatMessageIterator.hasNext()) { + val currentMessage = chatMessageIterator.next() + + if (isThreadChildMessage(currentMessage)) { + chatMessageIterator.remove() + } + } + } + + return chatMessageMap.values.toList() + } + + // val timeString = DateUtils.getLocalTimeStringFromTimestamp(message.timestamp) + + fun getAvatarUrl(message: ChatMessage): String = + if (this::currentUser.isInitialized) { + ApiUtils.getUrlForAvatar( + currentUser.baseUrl, + message.actorId, + false + ) + } else { + "" + } + + fun initData(user: User, credentials: String, urlForChatting: String, threadId: Long?) { currentUser = user chatRepository.initData( user, credentials, urlForChatting, - roomToken, + chatRoomToken, threadId ) - chatRoomToken = roomToken - } - fun updateConversation(currentConversation: ConversationModel) { - chatRepository.updateConversation(currentConversation) + observeConversationAndUserFirstTime() + observeConversationAndUserEveryTime() } + fun ConversationModel?.isOneToOneConversation(): Boolean = + this?.type == + ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + + @Deprecated("use observeConversation") fun getRoom(token: String) { - _getRoomViewState.value = GetRoomStartState + // _getRoomViewState.value = GetRoomStartState conversationRepository.getRoom(currentUser, token) } @@ -343,7 +717,8 @@ class ChatViewModel @Inject constructor( if (conversationModel.remoteServer.isNullOrEmpty()) { if (_getCapabilitiesViewState.value == GetCapabilitiesStartState) { _getCapabilitiesViewState.value = GetCapabilitiesInitialLoadState( - user.capabilities!!.spreedCapability!! + user.capabilities!!.spreedCapability!!, + conversationModel ) } else { _getCapabilitiesViewState.value = GetCapabilitiesUpdateState(user.capabilities!!.spreedCapability!!) @@ -363,7 +738,10 @@ class ChatViewModel @Inject constructor( override fun onNext(spreedCapabilities: SpreedCapability) { if (_getCapabilitiesViewState.value == GetCapabilitiesStartState) { - _getCapabilitiesViewState.value = GetCapabilitiesInitialLoadState(spreedCapabilities) + _getCapabilitiesViewState.value = GetCapabilitiesInitialLoadState( + spreedCapabilities, + conversationModel + ) } else { _getCapabilitiesViewState.value = GetCapabilitiesUpdateState(spreedCapabilities) } @@ -456,7 +834,6 @@ class ChatViewModel @Inject constructor( override fun onNext(t: GenericOverall) { _leaveRoomViewState.value = LeaveRoomSuccessState(funToCallWhenLeaveSuccessful) _getCapabilitiesViewState.value = GetCapabilitiesStartState - _getRoomViewState.value = GetRoomStartState } }) } @@ -524,13 +901,51 @@ class ChatViewModel @Inject constructor( } } - fun loadMessages(withCredentials: String, withUrl: String) { + suspend fun loadInitialMessages(withCredentials: String, withUrl: String, isChatRelaySupported: Boolean) { val bundle = Bundle() bundle.putString(BundleKeys.KEY_CHAT_URL, withUrl) bundle.putString(BundleKeys.KEY_CREDENTIALS, withCredentials) - chatRepository.initScopeAndLoadInitialMessages( - withNetworkParams = bundle + chatRepository.loadInitialMessages( + withNetworkParams = bundle, + isChatRelaySupported = isChatRelaySupported ) + _events.emit(ChatEvent.StartRegularPolling) + } + + suspend fun startMessagePolling(hasHighPerformanceBackend: Boolean) { + chatRepository.startMessagePolling(hasHighPerformanceBackend) + } + + fun loadMoreMessagesCompose() { + val currentItems = _uiState.value.items + + val messageId = currentItems + .asReversed() + .firstNotNullOfOrNull { item -> + (item as? ChatItem.MessageItem)?.uiMessage?.id + } + + Log.d(TAG, "Compose load more, messageId: $messageId") + + messageId?.let { + val user = currentUserFlow.value + + val urlForChatting = ApiUtils.getUrlForChat( + 1, + user?.baseUrl, + chatRoomToken + ) + + val credentials = ApiUtils.getCredentials(user?.username, user?.token) + + loadMoreMessages( + beforeMessageId = it.toLong(), + withUrl = urlForChatting, + withCredentials = credentials!!, + withMessageLimit = 100, + roomToken = uiState.value.conversation!!.token + ) + } } fun loadMoreMessages( @@ -540,15 +955,17 @@ class ChatViewModel @Inject constructor( withCredentials: String, withUrl: String ) { - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_CHAT_URL, withUrl) - bundle.putString(BundleKeys.KEY_CREDENTIALS, withCredentials) - chatRepository.loadMoreMessages( - beforeMessageId, - roomToken, - withMessageLimit, - withNetworkParams = bundle - ) + viewModelScope.launch { + val bundle = Bundle() + bundle.putString(BundleKeys.KEY_CHAT_URL, withUrl) + bundle.putString(BundleKeys.KEY_CREDENTIALS, withCredentials) + chatRepository.loadMoreMessages( + beforeMessageId, + roomToken, + withMessageLimit, + withNetworkParams = bundle + ) + } } // fun initMessagePolling(withCredentials: String, withUrl: String, roomToken: String) { @@ -587,8 +1004,26 @@ class ChatViewModel @Inject constructor( }) } - fun setChatReadMarker(credentials: String, url: String, previousMessageId: Int) { - chatNetworkDataSource.setChatReadMarker(credentials, url, previousMessageId) + fun advanceLocalLastReadMessageIfNeeded(messageId: Int) { + if (localLastReadMessage < messageId) { + localLastReadMessage = messageId + } + } + + /** + * Please use with caution to not spam the server + */ + fun updateRemoteLastReadMessageIfNeeded(credentials: String, url: String) { + if (localLastReadMessage > _uiState.value.conversation!!.lastReadMessage) { + setChatReadMessage(credentials, url, localLastReadMessage) + } + } + + /** + * Please use with caution to not spam the server + */ + fun setChatReadMessage(credentials: String, url: String, lastReadMessage: Int) { + chatNetworkDataSource.setChatReadMarker(credentials, url, lastReadMessage) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(object : Observer { @@ -853,43 +1288,77 @@ class ChatViewModel @Inject constructor( _getCapabilitiesViewState.value = GetCapabilitiesStartState } - fun getMessageById(url: String, conversationModel: ConversationModel, messageId: Long): Flow = - flow { - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_CHAT_URL, url) - bundle.putString( - BundleKeys.KEY_CREDENTIALS, - currentUser.getCredentials() - ) - bundle.putString(BundleKeys.KEY_ROOM_TOKEN, conversationModel.token) + // fun getMessageById(url: String, conversationModel: ConversationModel, messageId: Long): Flow = + // flow { + // val bundle = Bundle() + // bundle.putString(BundleKeys.KEY_CHAT_URL, url) + // bundle.putString( + // BundleKeys.KEY_CREDENTIALS, + // currentUser.getCredentials() + // ) + // bundle.putString(BundleKeys.KEY_ROOM_TOKEN, conversationModel.token) + // + // val message = chatRepository.getMessage(messageId, bundle) + // emit(message.first()) + // } + + @Deprecated("use getMessageById(messageId: Long)") + fun getMessageById( + url: String, + conversationModel: ConversationModel, + messageId: Long + ): Flow { + val bundle = Bundle().apply { + putString(BundleKeys.KEY_CHAT_URL, url) + putString(BundleKeys.KEY_CREDENTIALS, currentUser.getCredentials()) + putString(BundleKeys.KEY_ROOM_TOKEN, chatRoomToken) + } + + return chatRepository.getMessage(messageId, bundle) + } - val message = chatRepository.getMessage(messageId, bundle) - emit(message.first()) + fun getMessageById( + messageId: Long + ): Flow { + val urlForChatting = ApiUtils.getUrlForChat( + 1, // TODO: remove hardcoded value + currentUser?.baseUrl, + chatRoomToken + ) + + val bundle = Bundle().apply { + putString(BundleKeys.KEY_CHAT_URL, urlForChatting) + putString(BundleKeys.KEY_CREDENTIALS, currentUser.getCredentials()) + putString(BundleKeys.KEY_ROOM_TOKEN, chatRoomToken) } - fun getIndividualMessageFromServer( - credentials: String, - baseUrl: String, - token: String, - messageId: String - ): Flow = - flow { - val messages = chatNetworkDataSource.getContextForChatMessage( - credentials = credentials, - baseUrl = baseUrl, - token = token, - messageId = messageId, - limit = 1, - threadId = null - ) + return chatRepository.getMessage(messageId, bundle) + } - if (messages.isNotEmpty()) { - val message = messages[0] - emit(message.asModel()) - } else { - emit(null) - } - }.flowOn(Dispatchers.IO) + + // fun getIndividualMessageFromServer( + // credentials: String, + // baseUrl: String, + // token: String, + // messageId: String + // ): Flow = + // flow { + // val messages = chatNetworkDataSource.getContextForChatMessage( + // credentials = credentials, + // baseUrl = baseUrl, + // token = token, + // messageId = messageId, + // limit = 1, + // threadId = null + // ) + // + // if (messages.isNotEmpty()) { + // val message = messages[0] + // emit(message.toDomainModel()) + // } else { + // emit(null) + // } + // }.flowOn(Dispatchers.IO) suspend fun getNumberOfThreadReplies(threadId: Long): Int = chatRepository.getNumberOfThreadReplies(threadId) @@ -1118,4 +1587,38 @@ class ChatViewModel @Inject constructor( data class Success(val thread: ThreadInfo?) : ThreadRetrieveUiState() data class Error(val exception: Exception) : ThreadRetrieveUiState() } + + sealed class ChatEvent { + object Initial : ChatEvent() + object StartRegularPolling : ChatEvent() + object Loading : ChatEvent() + object Ready : ChatEvent() + data class Error(val throwable: Throwable) : ChatEvent() + } + + sealed interface ChatItem { + fun messageOrNull(): ChatMessageUi? = (this as? MessageItem)?.uiMessage + fun dateOrNull(): LocalDate? = (this as? DateHeaderItem)?.date + + fun stableKey(): Any = + when (this) { + is MessageItem -> "msg_${uiMessage.id}" + is DateHeaderItem -> "header_$date" + is UnreadMessagesMarkerItem -> "last_read_$date" + } + + // TODO do not include whole ChatMessage here. Extract the things that are needed in UI to ChatMessageUi and + // then delete ChatMessage! + data class MessageItem( + // val message: ChatMessage, + val uiMessage: ChatMessageUi + ) : ChatItem + data class DateHeaderItem(val date: LocalDate) : ChatItem + data class UnreadMessagesMarkerItem(val date: LocalDate) : ChatItem + } + + @AssistedFactory + interface ChatViewModelFactory { + fun create(roomToken: String, conversationThreadId: Long?): ChatViewModel + } } diff --git a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt index 5fb014e2ba5..4985da0f33f 100644 --- a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -49,19 +48,27 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.nextcloud.talk.R -import com.nextcloud.talk.data.database.mappers.asModel +import com.nextcloud.talk.data.database.mappers.toDomainModel +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.chat.ChatMessageJson -import com.nextcloud.talk.ui.ComposeChatAdapter +import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.preview.ComposePreviewUtils @Composable -fun ContextChatView(context: Context, contextViewModel: ContextChatViewModel) { +fun ContextChatView( + user: User, + context: Context, + viewThemeUtils: ViewThemeUtils, + contextViewModel: ContextChatViewModel +) { val contextChatMessagesState = contextViewModel.getContextChatMessagesState.collectAsState().value when (contextChatMessagesState) { ContextChatViewModel.ContextChatRetrieveUiState.None -> {} is ContextChatViewModel.ContextChatRetrieveUiState.Success -> { ContextChatSuccessView( + user = user, + viewThemeUtils = viewThemeUtils, visible = true, context = context, contextChatRetrieveUiStateSuccess = contextChatMessagesState, @@ -96,6 +103,8 @@ fun ContextChatErrorView() { @Composable fun ContextChatSuccessView( + user: User, + viewThemeUtils: ViewThemeUtils, visible: Boolean, context: Context, contextChatRetrieveUiStateSuccess: ContextChatViewModel.ContextChatRetrieveUiState.Success, @@ -168,18 +177,16 @@ fun ContextChatSuccessView( // ComposeChatMenu(colorScheme.background, false) } - val messages = contextChatRetrieveUiStateSuccess.messages.map(ChatMessageJson::asModel) + val messages = contextChatRetrieveUiStateSuccess.messages.map(ChatMessageJson::toDomainModel) val messageId = contextChatRetrieveUiStateSuccess.messageId val threadId = contextChatRetrieveUiStateSuccess.threadId - val adapter = ComposeChatAdapter( - messagesJson = contextChatRetrieveUiStateSuccess.messages, - messageId = messageId, - threadId = threadId - ) - SideEffect { - adapter.addMessages(messages.toMutableList(), true) - } - adapter.GetView() + + // TODO refactor context chat + // GetNewChatView( + // chatItems = messages, + // conversationThreadId = threadId?.toLong(), + // null + // ) } } } diff --git a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt index e1aaf175e95..834dc7d4a30 100644 --- a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt @@ -12,7 +12,6 @@ import androidx.lifecycle.viewModelScope import autodagger.AutoInjector import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource -import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.users.UserManager import kotlinx.coroutines.flow.MutableStateFlow @@ -24,9 +23,6 @@ import javax.inject.Inject class ContextChatViewModel @Inject constructor(private val chatNetworkDataSource: ChatNetworkDataSource) : ViewModel() { - @Inject - lateinit var chatViewModel: ChatViewModel - @Inject lateinit var userManager: UserManager diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 27050f040d2..f5af3befe2e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -39,7 +39,6 @@ import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat @@ -85,7 +84,6 @@ import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.chat.ChatActivity -import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contextchat.ContextChatView @@ -108,7 +106,6 @@ import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.settings.SettingsActivity import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity -import com.nextcloud.talk.ui.BackgroundVoiceMessageCard import com.nextcloud.talk.ui.dialog.ChooseAccountDialogCompose import com.nextcloud.talk.ui.dialog.ChooseAccountShareToDialogFragment import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog @@ -158,7 +155,6 @@ import org.apache.commons.lang3.builder.CompareToBuilder import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import retrofit2.HttpException -import java.io.File import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -192,9 +188,6 @@ class ConversationsListActivity : @Inject lateinit var networkMonitor: NetworkMonitor - @Inject - lateinit var chatViewModel: ChatViewModel - @Inject lateinit var contactsViewModel: ContactsViewModel @@ -487,54 +480,57 @@ class ConversationsListActivity : }.collect() } - lifecycleScope.launch { - chatViewModel.backgroundPlayUIFlow.onEach { msg -> - binding.composeViewForBackgroundPlay.apply { - // Dispose of the Composition when the view's LifecycleOwner is destroyed - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - msg?.let { - val duration = chatViewModel.mediaPlayerDuration - val position = chatViewModel.mediaPlayerPosition - val offset = position.toFloat() / duration - val imageURI = ApiUtils.getUrlForAvatar( - currentUser?.baseUrl, - msg.actorId, - true - ) - val conversationImageURI = ApiUtils.getUrlForConversationAvatar( - ApiUtils.API_V1, - currentUser?.baseUrl, - msg.token - ) - - if (duration > 0) { - BackgroundVoiceMessageCard( - msg.actorDisplayName!!, - duration - position, - offset, - imageURI, - conversationImageURI, - viewThemeUtils, - context - ) - .GetView({ isPaused -> - if (isPaused) { - chatViewModel.pauseMediaPlayer(false) - } else { - val filename = msg.selectedIndividualHashMap!!["name"] - val file = File(context.cacheDir, filename!!) - chatViewModel.startMediaPlayer(file.canonicalPath) - } - }) { - chatViewModel.stopMediaPlayer() - } - } - } - } - } - }.collect() - } + // TODO: playback of background voice messages must be reimplemented. It's not okay to use the chatViewModel + // in conversation list. Instead, reimplement playback with a foreground service?! + + // lifecycleScope.launch { + // chatViewModel.backgroundPlayUIFlow.onEach { msg -> + // binding.composeViewForBackgroundPlay.apply { + // // Dispose of the Composition when the view's LifecycleOwner is destroyed + // setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + // setContent { + // msg?.let { + // val duration = chatViewModel.mediaPlayerDuration + // val position = chatViewModel.mediaPlayerPosition + // val offset = position.toFloat() / duration + // val imageURI = ApiUtils.getUrlForAvatar( + // currentUser?.baseUrl, + // msg.actorId, + // true + // ) + // val conversationImageURI = ApiUtils.getUrlForConversationAvatar( + // ApiUtils.API_V1, + // currentUser?.baseUrl, + // msg.token + // ) + // + // if (duration > 0) { + // BackgroundVoiceMessageCard( + // msg.actorDisplayName!!, + // duration - position, + // offset, + // imageURI, + // conversationImageURI, + // viewThemeUtils, + // context + // ) + // .GetView({ isPaused -> + // if (isPaused) { + // chatViewModel.pauseMediaPlayer(false) + // } else { + // val filename = msg.selectedIndividualHashMap!!["name"] + // val file = File(context.cacheDir, filename!!) + // chatViewModel.startMediaPlayer(file.canonicalPath) + // } + // }) { + // chatViewModel.stopMediaPlayer() + // } + // } + // } + // } + // } + // }.collect() + // } } private fun handleNoteToSelfShortcut(noteToSelfAvailable: Boolean, noteToSelfToken: String) { @@ -1459,7 +1455,12 @@ class ConversationsListActivity : messageId = item.messageEntry.messageId!!, title = item.messageEntry.title ) - ContextChatView(context, contextChatViewModel) + ContextChatView( + user = currentUser!!, + context = context, + viewThemeUtils = viewThemeUtils, + contextViewModel = contextChatViewModel + ) } } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt index a9f80c0189a..2e10573ee9b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt @@ -7,6 +7,7 @@ package com.nextcloud.talk.conversationlist.data +import com.nextcloud.talk.conversationlist.data.network.OfflineFirstConversationsRepository.ConversationResult import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.ConversationModel import kotlinx.coroutines.Job @@ -22,6 +23,7 @@ interface OfflineConversationsRepository { /** * Stream of a single conversation, for use in each conversations settings. */ + @Deprecated("use observeConversation") val conversationFlow: Flow /** @@ -30,15 +32,20 @@ interface OfflineConversationsRepository { * emits to [roomListFlow] if the rooms list is not empty. * */ + @Deprecated("use observeConversation") fun getRooms(user: User): Job /** * Called once onStart to emit a conversation to [conversationFlow] * to be handled asynchronously. */ + @Deprecated("use observeConversation") fun getRoom(user: User, roomToken: String): Job suspend fun updateConversation(conversationModel: ConversationModel) + @Deprecated("use observeConversation") suspend fun getLocallyStoredConversation(user: User, roomToken: String): ConversationModel? + + fun observeConversation(accountId: Long, roomToken: String): Flow } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt index 84b9e2c0045..a98cd7bbf7e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt @@ -13,7 +13,7 @@ import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository import com.nextcloud.talk.data.database.dao.ConversationsDao import com.nextcloud.talk.data.database.mappers.asEntity -import com.nextcloud.talk.data.database.mappers.asModel +import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.data.database.model.ConversationEntity import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User @@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import javax.inject.Inject +import kotlin.collections.map class OfflineFirstConversationsRepository @Inject constructor( private val dao: ConversationsDao, @@ -50,6 +51,24 @@ class OfflineFirstConversationsRepository @Inject constructor( private val scope = CoroutineScope(Dispatchers.IO) + sealed interface ConversationResult { + data class Found(val conversation: ConversationModel) : ConversationResult + object NotFound : ConversationResult + } + + override fun observeConversation(accountId: Long, roomToken: String): Flow = + dao.getConversationForUser( + accountId, + roomToken + ) + .map { entity -> + if (entity == null) { + ConversationResult.NotFound + } else { + ConversationResult.Found(entity.toDomainModel()) + } + } + override fun getRooms(user: User): Job = scope.launch { val initialConversationModels = getListOfConversations(user.id!!) @@ -58,7 +77,7 @@ class OfflineFirstConversationsRepository @Inject constructor( if (networkMonitor.isOnline.value) { val conversationEntitiesFromSync = getRoomsFromServer(user) if (!conversationEntitiesFromSync.isNullOrEmpty()) { - val conversationModelsFromSync = conversationEntitiesFromSync.map(ConversationEntity::asModel) + val conversationModelsFromSync = conversationEntitiesFromSync.map(ConversationEntity::toDomainModel) _roomListFlow.emit(conversationModelsFromSync) } } @@ -156,12 +175,12 @@ class OfflineFirstConversationsRepository @Inject constructor( private suspend fun getListOfConversations(accountId: Long): List = dao.getConversationsForUser(accountId).map { - it.map(ConversationEntity::asModel) + it.map(ConversationEntity::toDomainModel) }.first() private suspend fun getConversation(accountId: Long, token: String): ConversationModel? { val entity = dao.getConversationForUser(accountId, token).first() - return entity?.asModel() + return entity?.toDomainModel() } companion object { diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 2b5c699b814..e03c2d1a705 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -11,7 +11,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.nextcloud.talk.account.viewmodels.BrowserLoginActivityViewModel import com.nextcloud.talk.activities.CallViewModel -import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.chat.viewmodels.ScheduledMessagesViewModel import com.nextcloud.talk.chooseaccount.StatusViewModel import com.nextcloud.talk.contacts.ContactsViewModel @@ -48,6 +47,14 @@ class ViewModelFactory @Inject constructor( override fun create(modelClass: Class): T = viewModels[modelClass]?.get() as T } +class ViewModelFactoryWithParams(private val modelClass: Class, private val create: () -> T) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return create() as T + } +} + @Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) @Retention(AnnotationRetention.RUNTIME) @MapKey @@ -120,10 +127,10 @@ abstract class ViewModelModule { @ViewModelKey(ConversationsListViewModel::class) abstract fun conversationsListViewModel(viewModel: ConversationsListViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(ChatViewModel::class) - abstract fun chatViewModel(viewModel: ChatViewModel): ViewModel + // @Binds + // @IntoMap + // @ViewModelKey(ChatViewModel::class) + // abstract fun chatViewModel(viewModel: ChatViewModel): ViewModel @Binds @IntoMap @@ -185,3 +192,11 @@ abstract class ViewModelModule { @ViewModelKey(ScheduledMessagesViewModel::class) abstract fun scheduledMessagesViewModel(viewModel: ScheduledMessagesViewModel): ViewModel } + +// @Module +// interface ChatViewModelAssistedModule { +// @Binds +// fun bindChatViewModelFactory( +// factory: ChatViewModel.Factory +// ): ChatViewModel.Factory +// } diff --git a/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatBlocksDao.kt b/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatBlocksDao.kt index 600f6a03d0d..b751b9a2fbf 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatBlocksDao.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatBlocksDao.kt @@ -81,4 +81,16 @@ interface ChatBlocksDao { """ ) fun deleteChatBlocksOlderThan(internalConversationId: String, messageId: Long) + + @Query( + """ + SELECT * + FROM ChatBlocks + WHERE internalConversationId = :internalConversationId + AND (threadId = :threadId OR (threadId IS NULL AND :threadId IS NULL)) + ORDER BY newestMessageId DESC + LIMIT 1 + """ + ) + fun getLatestChatBlock(internalConversationId: String, threadId: Long?): Flow } diff --git a/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt b/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt index 13f24a0211f..f7deacd46fa 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt @@ -18,16 +18,43 @@ import kotlinx.coroutines.flow.Flow @Dao @Suppress("Detekt.TooManyFunctions") interface ChatMessagesDao { + + // """ + // SELECT * + // FROM ChatMessages + // WHERE internalConversationId = :internalConversationId AND id >= :messageId + // AND isTemporary = 0 + // AND (:threadId IS NULL OR threadId = :threadId) + // ORDER BY timestamp ASC, id ASC + // """ + + @Query( + """ + SELECT * + FROM ChatMessages + WHERE internalConversationId = :internalConversationId + AND (:threadId IS NULL OR threadId = :threadId) + AND id > :oldestMessageId + ORDER BY timestamp ASC, id ASC + """ + ) + fun getMessagesNewerThan( + internalConversationId: String, + threadId: Long?, + oldestMessageId: Long + ): Flow> + @Query( """ SELECT * FROM ChatMessages WHERE internalConversationId = :internalConversationId AND isTemporary = 0 + AND (:threadId IS NULL OR threadId = :threadId) ORDER BY timestamp DESC, id DESC """ ) - fun getMessagesForConversation(internalConversationId: String): Flow> + fun getMessagesForConversation(internalConversationId: String, threadId: Long?): Flow> @Query( """ @@ -79,6 +106,7 @@ interface ChatMessagesDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertChatMessage(chatMessage: ChatMessageEntity) + @Deprecated("use getChatMessageEntity") @Query( """ SELECT * @@ -89,6 +117,18 @@ interface ChatMessagesDao { ) fun getChatMessageForConversation(internalConversationId: String, messageId: Long): Flow + @Deprecated("use getChatMessageEntity") + @Query( + """ + SELECT * + FROM ChatMessages + WHERE internalConversationId = :internalConversationId + AND id = :messageId + """ + ) + suspend fun getChatMessageOnce(internalConversationId: String, messageId: Long): ChatMessageEntity? + + @Deprecated("use getChatMessageEntity") @Query( """ SELECT * @@ -97,8 +137,33 @@ interface ChatMessagesDao { AND id = :messageId """ ) + fun getChatMessageForConversationNullable(internalConversationId: String, messageId: Long): Flow + + @Query( + """ + SELECT * + FROM ChatMessages + WHERE internalConversationId = :internalConversationId + AND id = :messageId + LIMIT 1 + """ + ) suspend fun getChatMessageEntity(internalConversationId: String, messageId: Long): ChatMessageEntity? + @Query( + """ + SELECT * + FROM ChatMessages + WHERE internalConversationId = :internalConversationId + AND id = :messageId + LIMIT 1 + """ + ) + fun observeMessage( + internalConversationId: String, + messageId: Long + ): Flow + @Query( value = """ DELETE FROM ChatMessages diff --git a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt index e7ef0df94fa..590d14b4d6d 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt @@ -10,7 +10,6 @@ package com.nextcloud.talk.data.database.mappers import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.models.json.chat.ChatMessageJson -import com.nextcloud.talk.models.json.chat.ReadStatus fun ChatMessageJson.asEntity(accountId: Long) = ChatMessageEntity( @@ -53,7 +52,7 @@ fun ChatMessageJson.asEntity(accountId: Long) = sendAt = sendAt ) -fun ChatMessageEntity.asModel() = +fun ChatMessageEntity.toDomainModel() = ChatMessage( jsonMessageId = id.toInt(), message = message, @@ -81,7 +80,7 @@ fun ChatMessageEntity.asModel() = referenceId = referenceId, isTemporary = isTemporary, sendStatus = sendStatus, - readStatus = ReadStatus.NONE, + // readStatus = ReadStatus.NONE, silent = silent, threadTitle = threadTitle, threadReplies = threadReplies, @@ -93,7 +92,7 @@ fun ChatMessageEntity.asModel() = sendAt = sendAt ) -fun ChatMessageJson.asModel() = +fun ChatMessageJson.toDomainModel() = ChatMessage( jsonMessageId = id.toInt(), message = message, diff --git a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt index 7dad703069c..45946b7c8c7 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt @@ -69,7 +69,7 @@ fun ConversationModel.asEntity() = lastPinnedId = lastPinnedId ) -fun ConversationEntity.asModel() = +fun ConversationEntity.toDomainModel() = ConversationModel( internalId = internalId, accountId = accountId, diff --git a/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt b/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt index 53ccb27a431..1c9ee4f7480 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt @@ -27,7 +27,8 @@ import com.nextcloud.talk.chat.data.model.ChatMessage ], indices = [ Index(value = ["internalId"], unique = true), - Index(value = ["internalConversationId"]) + Index(value = ["internalConversationId"]), + Index(value = ["referenceId"], unique = true) ] ) data class ChatMessageEntity( diff --git a/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt b/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt index dc5e8ace347..75bf0ec0611 100644 --- a/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt +++ b/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt @@ -100,6 +100,13 @@ object Migrations { } } + val MIGRATION_23_24 = object : Migration(23, 24) { + override fun migrate(db: SupportSQLiteDatabase) { + Log.i("Migrations", "Migrating 23 to 24") + migrateToUniqueReferenceIds(db) + } + } + //endregion fun migrateToRoom(db: SupportSQLiteDatabase) { @@ -418,4 +425,35 @@ object Migrations { Log.i("Migrations", "Something went wrong when adding column silent to table ChatMessages", e) } } + + fun migrateToUniqueReferenceIds(db: SupportSQLiteDatabase) { + // referenceId could exist multiple times (they should not, but just in case..). Before migrating to unique + // index, make sure to delete all duplicates. + db.execSQL( + """ + DELETE FROM ChatMessages + WHERE rowid NOT IN ( + -- Keep the latest non-temporary per referenceId + SELECT MAX(rowid) + FROM ChatMessages + WHERE referenceId IS NOT NULL + GROUP BY referenceId + UNION + -- Keep all messages without referenceId + SELECT rowid + FROM ChatMessages + WHERE referenceId IS NULL + ) + """ + ) + + // Now it's safe to create the unique index + db.execSQL( + """ + CREATE UNIQUE INDEX IF NOT EXISTS + index_ChatMessages_referenceId + ON ChatMessages(referenceId) + """ + ) + } } diff --git a/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt b/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt index 8e8822aec5e..70f71b86e92 100644 --- a/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt +++ b/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt @@ -48,7 +48,7 @@ import java.util.Locale ChatMessageEntity::class, ChatBlockEntity::class ], - version = 23, + version = 24, autoMigrations = [ AutoMigration(from = 9, to = 10), AutoMigration(from = 16, to = 17, spec = AutoMigration16To17::class), @@ -125,7 +125,7 @@ abstract class TalkDatabase : RoomDatabase() { return Room .databaseBuilder(context.applicationContext, TalkDatabase::class.java, dbName) // comment out openHelperFactory to view the database entries in Android Studio for debugging - .openHelperFactory(factory) + // .openHelperFactory(factory) .fallbackToDestructiveMigrationFrom(true, 18) .addMigrations(*MIGRATIONS) // * converts migrations to vararg .allowMainThreadQueries() diff --git a/app/src/main/java/com/nextcloud/talk/models/json/websocket/HelloWebSocketMessage.kt b/app/src/main/java/com/nextcloud/talk/models/json/websocket/HelloWebSocketMessage.kt index 0ab33200a5d..d3265bb31b6 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/websocket/HelloWebSocketMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/websocket/HelloWebSocketMessage.kt @@ -20,7 +20,9 @@ data class HelloWebSocketMessage( @JsonField(name = ["resumeid"]) var resumeid: String? = null, @JsonField(name = ["auth"]) - var authWebSocketMessage: AuthWebSocketMessage? = null + var authWebSocketMessage: AuthWebSocketMessage? = null, + @JsonField(name = ["features"]) + var features: List? = null ) : Parcelable { // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' constructor() : this(null, null, null) diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index db996b99774..06838f2615e 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -13,6 +13,7 @@ import android.os.Bundle import android.util.Log import android.view.MenuItem import android.view.View +import androidx.activity.viewModels import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager @@ -25,6 +26,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.contextchat.ContextChatView import com.nextcloud.talk.contextchat.ContextChatViewModel +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivitySharedItemsBinding import com.nextcloud.talk.shareditems.adapters.SharedItemsAdapter @@ -32,7 +34,9 @@ import com.nextcloud.talk.shareditems.model.SharedItemType import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID import javax.inject.Inject +import kotlin.getValue @AutoInjector(NextcloudTalkApplication::class) class SharedItemsActivity : BaseActivity() { @@ -41,7 +45,29 @@ class SharedItemsActivity : BaseActivity() { lateinit var viewModelFactory: ViewModelProvider.Factory @Inject - lateinit var chatViewModel: ChatViewModel + lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory + + val roomToken: String by lazy { + intent.getStringExtra(KEY_ROOM_TOKEN) + ?: error("roomToken missing") + } + + val conversationThreadId: Long? by lazy { + if (intent.hasExtra(KEY_THREAD_ID)) { + intent.getLongExtra(KEY_THREAD_ID, 0L) + } else { + null + } + } + + val chatViewModel: ChatViewModel by viewModels { + ViewModelFactoryWithParams(ChatViewModel::class.java) { + chatViewModelFactory.create( + roomToken, + conversationThreadId + ) + } + } @Inject lateinit var contextChatViewModel: ContextChatViewModel @@ -52,8 +78,6 @@ class SharedItemsActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - - val roomToken = intent.getStringExtra(KEY_ROOM_TOKEN)!! val conversationName = intent.getStringExtra(KEY_CONVERSATION_NAME) val user = currentUserProviderOld.currentUser.blockingGet() @@ -156,7 +180,12 @@ class SharedItemsActivity : BaseActivity() { messageId = messageId!!, title = "" ) - ContextChatView(context, contextChatViewModel) + ContextChatView( + user = currentUserProviderOld.currentUser.blockingGet(), + context, + viewThemeUtils = viewThemeUtils, + contextChatViewModel + ) } } Log.d(TAG, "Should open something else") diff --git a/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt b/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt index 9bd2408abe9..5b5e4dbf242 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt +++ b/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt @@ -6,6 +6,7 @@ */ package com.nextcloud.talk.signaling +import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.signaling.SignalingMessageReceiver.ConversationMessageListener internal class ConversationMessageNotifier { @@ -29,6 +30,13 @@ internal class ConversationMessageNotifier { } } + @Synchronized + fun notifyMessageReceived(chatMessage: ChatMessageJson) { + for (listener in ArrayList(conversationMessageListeners)) { + listener.onChatMessageReceived(chatMessage) + } + } + fun notifyStopTyping(userId: String?, sessionId: String?) { for (listener in ArrayList(conversationMessageListeners)) { listener.onStopTyping(userId, sessionId) diff --git a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt similarity index 64% rename from app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java rename to app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt index 397ba7b55ce..cd531eba1e3 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java +++ b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt @@ -4,265 +4,278 @@ * SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez * SPDX-License-Identifier: GPL-3.0-or-later */ -package com.nextcloud.talk.signaling; - -import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter; -import com.nextcloud.talk.models.json.converters.EnumParticipantTypeConverter; -import com.nextcloud.talk.models.json.participants.Participant; -import com.nextcloud.talk.models.json.signaling.NCIceCandidate; -import com.nextcloud.talk.models.json.signaling.NCMessagePayload; -import com.nextcloud.talk.models.json.signaling.NCSignalingMessage; -import com.nextcloud.talk.models.json.websocket.CallWebSocketMessage; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; +package com.nextcloud.talk.signaling + +import com.bluelinelabs.logansquare.LoganSquare +import com.nextcloud.talk.models.json.chat.ChatMessageJson +import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter +import com.nextcloud.talk.models.json.converters.EnumParticipantTypeConverter +import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.models.json.signaling.NCSignalingMessage +import com.nextcloud.talk.models.json.websocket.CallWebSocketMessage +import org.json.JSONObject +import kotlin.Any +import kotlin.Int +import kotlin.Long +import kotlin.RuntimeException +import kotlin.String +import kotlin.toString /** * Hub to register listeners for signaling messages of different kinds. - *

+ * * In general, if a listener is added while an event is being handled the new listener will not receive that event. * An exception to that is adding a WebRtcMessageListener when handling an offer in an OfferMessageListener; in that * case the "onOffer()" method of the WebRtcMessageListener will be called for that same offer. - *

+ * * Similarly, if a listener is removed while an event is being handled the removed listener will still receive that * event. Again the exception is removing a WebRtcMessageListener when handling an offer in an OfferMessageListener; in * that case the "onOffer()" method of the WebRtcMessageListener will not be called for that offer. - *

+ * + * * Adding and removing listeners, as well as notifying them is internally synchronized. This should be kept in mind * if listeners are added or removed when handling an event to prevent deadlocks (nevertheless, just adding or * removing a listener in the same thread handling the event is fine, and in most cases it will be fine too if done * in a different thread, as long as the notifier thread is not forced to wait until the listener is added or removed). - *

+ * * SignalingMessageReceiver does not fetch the signaling messages itself; subclasses must fetch them and then call * the appropriate protected methods to process the messages and notify the listeners. */ -public abstract class SignalingMessageReceiver { - - private final EnumActorTypeConverter enumActorTypeConverter = new EnumActorTypeConverter(); +abstract class SignalingMessageReceiver { + private val enumActorTypeConverter = EnumActorTypeConverter() - private final ParticipantListMessageNotifier participantListMessageNotifier = new ParticipantListMessageNotifier(); + private val participantListMessageNotifier = ParticipantListMessageNotifier() - private final LocalParticipantMessageNotifier localParticipantMessageNotifier = new LocalParticipantMessageNotifier(); + private val localParticipantMessageNotifier = LocalParticipantMessageNotifier() - private final CallParticipantMessageNotifier callParticipantMessageNotifier = new CallParticipantMessageNotifier(); + private val callParticipantMessageNotifier = CallParticipantMessageNotifier() - private final ConversationMessageNotifier conversationMessageNotifier = new ConversationMessageNotifier(); + private val conversationMessageNotifier = ConversationMessageNotifier() - private final OfferMessageNotifier offerMessageNotifier = new OfferMessageNotifier(); + private val offerMessageNotifier = OfferMessageNotifier() - private final WebRtcMessageNotifier webRtcMessageNotifier = new WebRtcMessageNotifier(); + private val webRtcMessageNotifier = WebRtcMessageNotifier() /** * Listener for participant list messages. - *

+ * * The messages are implicitly bound to the room currently joined in the signaling server; listeners are expected * to know the current room. */ - public interface ParticipantListMessageListener { - + interface ParticipantListMessageListener { /** * List of all the participants in the room. - *

+ * * This message is received only when the internal signaling server is used. - *

+ * * The message is received periodically, and the participants may not have been modified since the last message. - *

+ * * Only the following participant properties are set: * - inCall * - lastPing * - sessionId * - userId (if the participant is not a guest) - *

+ * * "participantPermissions" is provided in the message (since Talk 13), but not currently set in the * participant. "publishingPermissions" was provided instead in Talk 12, but it was not used anywhere, so it is * ignored. * * @param participants all the participants (users and guests) in the room */ - void onUsersInRoom(List participants); + fun onUsersInRoom(participants: MutableList?) /** * List of all the participants in the call or the room (depending on what triggered the event). - *

+ * * This message is received only when the external signaling server is used. - *

+ * * The message is received when any participant changed, although what changed is not provided and should be * derived from the difference with previous messages. The list of participants may include only the * participants in the call (including those that just left it and thus triggered the event) or all the * participants currently in the room (participants in the room but not currently active, that is, without a * session, are not included). - *

+ * * Only the following participant properties are set: * - inCall * - lastPing * - sessionId * - type * - userId (if the participant is not a guest) - *

+ * * "nextcloudSessionId" is provided in the message (when the "inCall" property of any participant changed), but * not currently set in the participant. - *

+ * * "participantPermissions" is provided in the message (since Talk 13), but not currently set in the * participant. "publishingPermissions" was provided instead in Talk 12, but it was not used anywhere, so it is * ignored. * * @param participants all the participants (users and guests) in the room */ - void onParticipantsUpdate(List participants); + fun onParticipantsUpdate(participants: MutableList?) /** * Update of the properties of all the participants in the room. - *

+ * * This message is received only when the external signaling server is used. * * @param inCall the new value of the inCall property */ - void onAllParticipantsUpdate(long inCall); + fun onAllParticipantsUpdate(inCall: Long) } /** * Listener for local participant messages. - *

+ * * The messages are implicitly bound to the local participant (or, rather, its session); listeners are expected * to know the local participant. - *

+ * * The messages are related to the conversation, so the local participant may or may not be in a call when they * are received. */ - public interface LocalParticipantMessageListener { + fun interface LocalParticipantMessageListener { /** * Request for the client to switch to the given conversation. - *

+ * * This message is received only when the external signaling server is used. * * @param token the token of the conversation to switch to. */ - void onSwitchTo(String token); + fun onSwitchTo(token: String) } /** * Listener for call participant messages. - *

+ * + * * The messages are bound to a specific call participant (or, rather, session), so each listener is expected to * handle messages only for a single call participant. - *

+ * + * * Although "unshareScreen" is technically bound to a specific peer connection it is instead treated as a general * message on the call participant. */ - public interface CallParticipantMessageListener { - void onRaiseHand(boolean state, long timestamp); - void onReaction(String reaction); - void onUnshareScreen(); + interface CallParticipantMessageListener { + fun onRaiseHand(state: Boolean, timestamp: Long) + fun onReaction(reaction: String) + fun onUnshareScreen() } /** * Listener for conversation messages. */ - public interface ConversationMessageListener { - void onStartTyping(String userId, String session); - void onStopTyping(String userId,String session); + interface ConversationMessageListener { + fun onStartTyping(userId: String?, session: String?) + fun onStopTyping(userId: String?, session: String?) + fun onChatMessageReceived(chatMessage: ChatMessageJson) } /** * Listener for WebRTC offers. - *

+ * + * * Unlike the WebRtcMessageListener, which is bound to a specific peer connection, an OfferMessageListener listens * to all offer messages, no matter which peer connection they are bound to. This can be used, for example, to * create a new peer connection when a remote offer for which there is no previous connection is received. - *

+ * + * * When an offer is received all OfferMessageListeners are notified before any WebRtcMessageListener is notified. */ - public interface OfferMessageListener { - void onOffer(String sessionId, String roomType, String sdp, String nick); + fun interface OfferMessageListener { + fun onOffer(sessionId: String?, roomType: String, sdp: String?, nick: String?) } /** * Listener for WebRTC messages. - *

+ * + * * The messages are bound to a specific peer connection, so each listener is expected to handle messages only for * a single peer connection. */ - public interface WebRtcMessageListener { - void onOffer(String sdp, String nick); - void onAnswer(String sdp, String nick); - void onCandidate(String sdpMid, int sdpMLineIndex, String sdp); - void onEndOfCandidates(); + interface WebRtcMessageListener { + fun onOffer(sdp: String, nick: String?) + fun onAnswer(sdp: String, nick: String?) + fun onCandidate(sdpMid: String, sdpMLineIndex: Int, sdp: String) + fun onEndOfCandidates() } /** * Adds a listener for participant list messages. - *

+ * + * * A listener is expected to be added only once. If the same listener is added again it will be notified just once. * * @param listener the ParticipantListMessageListener */ - public void addListener(ParticipantListMessageListener listener) { - participantListMessageNotifier.addListener(listener); + fun addListener(listener: ParticipantListMessageListener?) { + participantListMessageNotifier.addListener(listener) } - public void removeListener(ParticipantListMessageListener listener) { - participantListMessageNotifier.removeListener(listener); + fun removeListener(listener: ParticipantListMessageListener?) { + participantListMessageNotifier.removeListener(listener) } /** * Adds a listener for local participant messages. - *

+ * + * * A listener is expected to be added only once. If the same listener is added again it will be notified just once. * * @param listener the LocalParticipantMessageListener */ - public void addListener(LocalParticipantMessageListener listener) { - localParticipantMessageNotifier.addListener(listener); + fun addListener(listener: LocalParticipantMessageListener?) { + localParticipantMessageNotifier.addListener(listener) } - public void removeListener(LocalParticipantMessageListener listener) { - localParticipantMessageNotifier.removeListener(listener); + fun removeListener(listener: LocalParticipantMessageListener?) { + localParticipantMessageNotifier.removeListener(listener) } /** * Adds a listener for call participant messages. - *

+ * + * * A listener is expected to be added only once. If the same listener is added again it will no longer be notified * for the messages from the previous session ID. * * @param listener the CallParticipantMessageListener * @param sessionId the ID of the session that messages come from */ - public void addListener(CallParticipantMessageListener listener, String sessionId) { - callParticipantMessageNotifier.addListener(listener, sessionId); + fun addListener(listener: CallParticipantMessageListener?, sessionId: String?) { + callParticipantMessageNotifier.addListener(listener, sessionId) } - public void removeListener(CallParticipantMessageListener listener) { - callParticipantMessageNotifier.removeListener(listener); + fun removeListener(listener: CallParticipantMessageListener?) { + callParticipantMessageNotifier.removeListener(listener) } - public void addListener(ConversationMessageListener listener) { - conversationMessageNotifier.addListener(listener); + fun addListener(listener: ConversationMessageListener?) { + conversationMessageNotifier.addListener(listener) } - public void removeListener(ConversationMessageListener listener) { - conversationMessageNotifier.removeListener(listener); + fun removeListener(listener: ConversationMessageListener) { + conversationMessageNotifier.removeListener(listener) } /** * Adds a listener for all offer messages. - *

+ * + * * A listener is expected to be added only once. If the same listener is added again it will be notified just once. * * @param listener the OfferMessageListener */ - public void addListener(OfferMessageListener listener) { - offerMessageNotifier.addListener(listener); + fun addListener(listener: OfferMessageListener?) { + offerMessageNotifier.addListener(listener) } - public void removeListener(OfferMessageListener listener) { - offerMessageNotifier.removeListener(listener); + fun removeListener(listener: OfferMessageListener?) { + offerMessageNotifier.removeListener(listener) } /** * Adds a listener for WebRTC messages from the given session ID and room type. - *

+ * + * * A listener is expected to be added only once. If the same listener is added again it will no longer be notified * for the messages from the previous session ID or room type. * @@ -270,29 +283,29 @@ public void removeListener(OfferMessageListener listener) { * @param sessionId the ID of the session that messages come from * @param roomType the room type that messages come from */ - public void addListener(WebRtcMessageListener listener, String sessionId, String roomType) { - webRtcMessageNotifier.addListener(listener, sessionId, roomType); + fun addListener(listener: WebRtcMessageListener?, sessionId: String?, roomType: String?) { + webRtcMessageNotifier.addListener(listener, sessionId, roomType) } - public void removeListener(WebRtcMessageListener listener) { - webRtcMessageNotifier.removeListener(listener); + fun removeListener(listener: WebRtcMessageListener?) { + webRtcMessageNotifier.removeListener(listener) } - protected void processEvent(Map eventMap) { - if ("room".equals(eventMap.get("target")) && "switchto".equals(eventMap.get("type"))) { - processSwitchToEvent(eventMap); + fun processEvent(eventMap: Map?) { + if ("room" == eventMap?.get("target") && "switchto" == eventMap["type"]) { + processSwitchToEvent(eventMap) - return; + return } - if ("participants".equals(eventMap.get("target")) && "update".equals(eventMap.get("type"))) { - processUpdateEvent(eventMap); + if ("participants" == eventMap?.get("target") && "update" == eventMap["type"]) { + processUpdateEvent(eventMap) - return; + return } } - private void processSwitchToEvent(Map eventMap) { + private fun processSwitchToEvent(eventMap: Map?) { // Message schema: // { // "type": "event", @@ -305,58 +318,81 @@ private void processSwitchToEvent(Map eventMap) { // }, // } - Map switchToMap; + val switchToMap: Map? try { - switchToMap = (Map) eventMap.get("switchto"); - } catch (RuntimeException e) { + switchToMap = eventMap?.get("switchto") as Map? + } catch (e: RuntimeException) { // Broken message, this should not happen. - return; + return } if (switchToMap == null) { // Broken message, this should not happen. - return; + return } - String token; + val token: String? try { - token = switchToMap.get("roomid").toString(); - } catch (RuntimeException e) { + token = switchToMap["roomid"].toString() + } catch (e: RuntimeException) { // Broken message, this should not happen. - return; + return + } + + localParticipantMessageNotifier.notifySwitchTo(token) + } + + protected fun processChatMessageWebSocketMessage(jsonString: String) { + fun parseChatMessage(jsonString: String): ChatMessageJson? { + return try { + val root = JSONObject(jsonString) + val eventObj = root.optJSONObject("event") ?: return null + val messageObj = eventObj.optJSONObject("message") ?: return null + val dataObj = messageObj.optJSONObject("data") ?: return null + val chatObj = dataObj.optJSONObject("chat") ?: return null + val commentObj = chatObj.optJSONObject("comment") ?: return null + + LoganSquare.parse(commentObj.toString(), ChatMessageJson::class.java) + } catch (e: Exception) { + null + } } - localParticipantMessageNotifier.notifySwitchTo(token); + val chatMessage = parseChatMessage(jsonString) + + chatMessage?.let { + conversationMessageNotifier.notifyMessageReceived(it) + } } - private void processUpdateEvent(Map eventMap) { - Map updateMap; + private fun processUpdateEvent(eventMap: Map?) { + val updateMap: Map? try { - updateMap = (Map) eventMap.get("update"); - } catch (RuntimeException e) { + updateMap = eventMap?.get("update") as Map? + } catch (e: RuntimeException) { // Broken message, this should not happen. - return; + return } if (updateMap == null) { // Broken message, this should not happen. - return; + return } - if (updateMap.get("all") != null && Boolean.parseBoolean(updateMap.get("all").toString())) { - processAllParticipantsUpdate(updateMap); + if (updateMap["all"] != null && updateMap["all"].toString().toBoolean()) { + processAllParticipantsUpdate(updateMap) - return; + return } - if (updateMap.get("users") != null) { - processParticipantsUpdate(updateMap); + if (updateMap["users"] != null) { + processParticipantsUpdate(updateMap) - return; + return } } - private void processAllParticipantsUpdate(Map updateMap) { + private fun processAllParticipantsUpdate(updateMap: Map) { // Message schema: // { // "type": "event", @@ -374,18 +410,18 @@ private void processAllParticipantsUpdate(Map updateMap) { // Note that "incall" in participants->update is all in lower case when the message applies to all participants, // even if it is "inCall" when the message provides separate properties for each participant. - long inCall; + val inCall: Long try { - inCall = Long.parseLong(updateMap.get("incall").toString()); - } catch (RuntimeException e) { + inCall = updateMap["incall"].toString().toLong() + } catch (e: RuntimeException) { // Broken message, this should not happen. - return; + return } - participantListMessageNotifier.notifyAllParticipantsUpdate(inCall); + participantListMessageNotifier.notifyAllParticipantsUpdate(inCall) } - private void processParticipantsUpdate(Map updateMap) { + private fun processParticipantsUpdate(updateMap: Map) { // Message schema: // { // "type": "event", @@ -416,34 +452,34 @@ private void processParticipantsUpdate(Map updateMap) { // Note that "userId" in participants->update comes from the Nextcloud server, so it is "userId"; in other // messages, like room->join, it comes directly from the external signaling server, so it is "userid" instead. - List> users; + val users: List>? try { - users = (List>) updateMap.get("users"); - } catch (RuntimeException e) { + users = updateMap["users"] as List>? + } catch (e: RuntimeException) { // Broken message, this should not happen. - return; + return } if (users == null) { // Broken message, this should not happen. - return; + return } - List participants = new ArrayList<>(users.size()); + val participants: MutableList = ArrayList(users.size) - for (Map user: users) { + for (user in users) { try { - participants.add(getParticipantFromMessageMap(user)); - } catch (RuntimeException e) { + participants.add(getParticipantFromMessageMap(user)) + } catch (e: RuntimeException) { // Broken message, this should not happen. - return; + return } } - participantListMessageNotifier.notifyParticipantsUpdate(participants); + participantListMessageNotifier.notifyParticipantsUpdate(participants) } - protected void processUsersInRoom(List> users) { + fun processUsersInRoom(users: List>) { // Message schema: // { // "type": "usersInRoom", @@ -462,23 +498,25 @@ protected void processUsersInRoom(List> users) { // ], // } - List participants = new ArrayList<>(users.size()); + val participants: MutableList = ArrayList(users.size) - for (Map user: users) { + for (user in users) { + val nullSafeUserMap = user as? Map ?: return try { - participants.add(getParticipantFromMessageMap(user)); - } catch (RuntimeException e) { + participants.add(getParticipantFromMessageMap(nullSafeUserMap)) + } catch (e: RuntimeException) { // Broken message, this should not happen. - return; + return } } - participantListMessageNotifier.notifyUsersInRoom(participants); + participantListMessageNotifier.notifyUsersInRoom(participants) } /** * Creates and initializes a Participant from the data in the given map. - *

+ * + * * Maps from internal and external signaling server messages can be used. Nevertheless, besides the differences * between the messages and the optional properties, it is expected that the message is correct and the given data * is parseable. Broken messages (for example, a string instead of an integer for "inCall" or a missing @@ -487,70 +525,73 @@ protected void processUsersInRoom(List> users) { * @param participantMap the map with the participant data * @return the Participant */ - private Participant getParticipantFromMessageMap(Map participantMap) { - Participant participant = new Participant(); + private fun getParticipantFromMessageMap(participantMap: Map): Participant { + val participant = Participant() - participant.setInCall(Long.parseLong(participantMap.get("inCall").toString())); - participant.setLastPing(Long.parseLong(participantMap.get("lastPing").toString())); - participant.setSessionId(participantMap.get("sessionId").toString()); + participant.inCall = participantMap["inCall"].toString().toLong() + participant.lastPing = participantMap["lastPing"].toString().toLong() + participant.sessionId = participantMap["sessionId"].toString() - if (participantMap.get("userId") != null && !participantMap.get("userId").toString().isEmpty()) { - participant.setUserId(participantMap.get("userId").toString()); + if (participantMap["userId"] != null && !participantMap["userId"].toString().isEmpty()) { + participant.userId = participantMap["userId"].toString() } - if (participantMap.get("internal") != null && Boolean.parseBoolean(participantMap.get("internal").toString())) { - participant.setInternal(Boolean.TRUE); + if (participantMap["internal"] != null && participantMap["internal"].toString().toBoolean()) { + participant.internal = true } - if (participantMap.get("actorType") != null && !participantMap.get("actorType").toString().isEmpty()) { - participant.setActorType(enumActorTypeConverter.getFromString(participantMap.get("actorType").toString())); + if (participantMap["actorType"] != null && !participantMap["actorType"].toString().isEmpty()) { + participant.actorType = enumActorTypeConverter.getFromString(participantMap["actorType"].toString()) } - if (participantMap.get("actorId") != null && !participantMap.get("actorId").toString().isEmpty()) { - participant.setActorId(participantMap.get("actorId").toString()); + if (participantMap["actorId"] != null && !participantMap["actorId"].toString().isEmpty()) { + participant.actorId = participantMap["actorId"].toString() } // Only in external signaling messages - if (participantMap.get("participantType") != null) { - int participantTypeInt = Integer.parseInt(participantMap.get("participantType").toString()); + if (participantMap["participantType"] != null) { + val participantTypeInt = participantMap["participantType"].toString().toInt() - EnumParticipantTypeConverter converter = new EnumParticipantTypeConverter(); - participant.setType(converter.getFromInt(participantTypeInt)); + val converter = EnumParticipantTypeConverter() + participant.type = converter.getFromInt(participantTypeInt) } - return participant; + return participant } - protected void processCallWebSocketMessage(CallWebSocketMessage callWebSocketMessage) { - - NCSignalingMessage signalingMessage = callWebSocketMessage.getNcSignalingMessage(); + protected fun processCallWebSocketMessage(callWebSocketMessage: CallWebSocketMessage) { + val signalingMessage = callWebSocketMessage.ncSignalingMessage - if (callWebSocketMessage.getSenderWebSocketMessage() != null && signalingMessage != null) { - String type = signalingMessage.getType(); + if (callWebSocketMessage.senderWebSocketMessage != null && signalingMessage != null) { + val type = signalingMessage.type - String userId = callWebSocketMessage.getSenderWebSocketMessage().getUserid(); - String sessionId = signalingMessage.getFrom(); + val userId = callWebSocketMessage.senderWebSocketMessage!!.userid + val sessionId = signalingMessage.from - if ("startedTyping".equals(type)) { - conversationMessageNotifier.notifyStartTyping(userId, sessionId); + if ("startedTyping" == type) { + conversationMessageNotifier.notifyStartTyping(userId, sessionId) } - if ("stoppedTyping".equals(type)) { - conversationMessageNotifier.notifyStopTyping(userId, sessionId); + if ("stoppedTyping" == type) { + conversationMessageNotifier.notifyStopTyping(userId, sessionId) } } } - protected void processSignalingMessage(NCSignalingMessage signalingMessage) { + fun processSignalingMessage(signalingMessage: NCSignalingMessage?) { + if (signalingMessage == null) { + return + } + // Note that in the internal signaling server message "data" is the String representation of a JSON // object, although it is already decoded when used here. - String type = signalingMessage.getType(); + val type = signalingMessage.type - String sessionId = signalingMessage.getFrom(); - String roomType = signalingMessage.getRoomType(); + val sessionId = signalingMessage.from + val roomType = signalingMessage.roomType - if ("raiseHand".equals(type)) { + if ("raiseHand" == type) { // Message schema (external signaling server): // { // "type": "message", @@ -588,26 +629,16 @@ protected void processSignalingMessage(NCSignalingMessage signalingMessage) { // }, // } - NCMessagePayload payload = signalingMessage.getPayload(); - if (payload == null) { - // Broken message, this should not happen. - return; - } - - Boolean state = payload.getState(); - Long timestamp = payload.getTimestamp(); + val payload = signalingMessage.payload ?: return + val state = payload.state ?: return + val timestamp = payload.timestamp ?: return - if (state == null || timestamp == null) { - // Broken message, this should not happen. - return; - } + callParticipantMessageNotifier.notifyRaiseHand(sessionId, state, timestamp) - callParticipantMessageNotifier.notifyRaiseHand(sessionId, state, timestamp); - - return; + return } - if ("reaction".equals(type)) { + if ("reaction" == type) { // Message schema (external signaling server): // { // "type": "message", @@ -641,27 +672,19 @@ protected void processSignalingMessage(NCSignalingMessage signalingMessage) { // }, // } - NCMessagePayload payload = signalingMessage.getPayload(); - if (payload == null) { - // Broken message, this should not happen. - return; - } + val payload = signalingMessage.payload ?: return - String reaction = payload.getReaction(); - if (reaction == null) { - // Broken message, this should not happen. - return; - } + val reaction = payload.reaction ?: return - callParticipantMessageNotifier.notifyReaction(sessionId, reaction); + callParticipantMessageNotifier.notifyReaction(sessionId, reaction) - return; + return } // "unshareScreen" messages are directly sent to the screen peer connection when the internal signaling // server is used, and to the room when the external signaling server is used. However, the (relevant) data // of the received message ("from" and "type") is the same in both cases. - if ("unshareScreen".equals(type)) { + if ("unshareScreen" == type) { // Message schema (external signaling server): // { // "type": "message", @@ -690,12 +713,12 @@ protected void processSignalingMessage(NCSignalingMessage signalingMessage) { // }, // } - callParticipantMessageNotifier.notifyUnshareScreen(sessionId); + callParticipantMessageNotifier.notifyUnshareScreen(sessionId) - return; + return } - if ("offer".equals(type)) { + if ("offer" == type) { // Message schema (external signaling server): // { // "type": "message", @@ -734,43 +757,35 @@ protected void processSignalingMessage(NCSignalingMessage signalingMessage) { // }, // } - NCMessagePayload payload = signalingMessage.getPayload(); - if (payload == null) { - // Broken message, this should not happen. - return; - } + val payload = signalingMessage.payload ?: return - String sdp = payload.getSdp(); - String nick = payload.getNick(); + val sdp = payload.sdp + val nick = payload.nick // If "processSignalingMessage" is called with two offers from two different threads it is possible, // although extremely unlikely, that the WebRtcMessageListeners for the second offer are notified before the // WebRtcMessageListeners for the first offer. This should not be a problem, though, so for simplicity // the statements are not synchronized. - offerMessageNotifier.notifyOffer(sessionId, roomType, sdp, nick); - webRtcMessageNotifier.notifyOffer(sessionId, roomType, sdp, nick); + offerMessageNotifier.notifyOffer(sessionId, roomType, sdp, nick) + webRtcMessageNotifier.notifyOffer(sessionId, roomType, sdp, nick) - return; + return } - if ("answer".equals(type)) { + if ("answer" == type) { // Message schema: same as offers, but with type "answer". - NCMessagePayload payload = signalingMessage.getPayload(); - if (payload == null) { - // Broken message, this should not happen. - return; - } + val payload = signalingMessage.payload ?: return - String sdp = payload.getSdp(); - String nick = payload.getNick(); + val sdp = payload.sdp + val nick = payload.nick - webRtcMessageNotifier.notifyAnswer(sessionId, roomType, sdp, nick); + webRtcMessageNotifier.notifyAnswer(sessionId, roomType, sdp, nick) - return; + return } - if ("candidate".equals(type)) { + if ("candidate" == type) { // Message schema (external signaling server): // { // "type": "message", @@ -814,31 +829,25 @@ protected void processSignalingMessage(NCSignalingMessage signalingMessage) { // }, // } - NCMessagePayload payload = signalingMessage.getPayload(); - if (payload == null) { - // Broken message, this should not happen. - return; - } + val payload = signalingMessage.payload ?: return - NCIceCandidate ncIceCandidate = payload.getIceCandidate(); - if (ncIceCandidate == null) { - // Broken message, this should not happen. - return; - } + val ncIceCandidate = payload.iceCandidate ?: return - webRtcMessageNotifier.notifyCandidate(sessionId, - roomType, - ncIceCandidate.getSdpMid(), - ncIceCandidate.getSdpMLineIndex(), - ncIceCandidate.getCandidate()); + webRtcMessageNotifier.notifyCandidate( + sessionId, + roomType, + ncIceCandidate.sdpMid, + ncIceCandidate.sdpMLineIndex, + ncIceCandidate.candidate + ) - return; + return } - if ("endOfCandidates".equals(type)) { - webRtcMessageNotifier.notifyEndOfCandidates(sessionId, roomType); + if ("endOfCandidates" == type) { + webRtcMessageNotifier.notifyEndOfCandidates(sessionId, roomType) - return; + return } } } diff --git a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt index d9492b70ebc..574f7283fe8 100644 --- a/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/threadsoverview/ThreadsOverviewActivity.kt @@ -47,7 +47,7 @@ import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.chat.ChatActivity.Companion.TAG import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.components.StandardAppBar -import com.nextcloud.talk.data.database.mappers.asModel +import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.models.json.threads.ThreadInfo import com.nextcloud.talk.threadsoverview.components.ThreadRow import com.nextcloud.talk.threadsoverview.viewmodels.ThreadsOverviewViewModel @@ -196,7 +196,7 @@ fun ThreadsList(threads: List, onThreadClick: (roomToken: String, th key = { threadInfo -> threadInfo.thread!!.id } ) { threadInfo -> val messageJson = threadInfo.last ?: threadInfo.first - val messageModel = messageJson?.asModel() + val messageModel = messageJson?.toDomainModel() ThreadRow( roomToken = threadInfo.thread!!.roomToken, diff --git a/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt b/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt deleted file mode 100644 index e0aa7c70c64..00000000000 --- a/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt +++ /dev/null @@ -1,1154 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2025 Julius Linus - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package com.nextcloud.talk.ui - -import android.content.Context -import android.util.Log -import android.view.View.TEXT_ALIGNMENT_VIEW_START -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import android.widget.LinearLayout -import androidx.compose.animation.animateColor -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.graphics.ColorUtils -import androidx.emoji2.widget.EmojiTextView -import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow -import autodagger.AutoInjector -import coil.compose.AsyncImage -import com.elyeproj.loaderviewlibrary.LoaderImageView -import com.elyeproj.loaderviewlibrary.LoaderTextView -import com.nextcloud.talk.R -import com.nextcloud.talk.adapters.messages.PreviewMessageViewHolder.Companion.KEY_MIMETYPE -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication -import com.nextcloud.talk.chat.data.model.ChatMessage -import com.nextcloud.talk.chat.viewmodels.ChatViewModel -import com.nextcloud.talk.contacts.ContactsViewModel -import com.nextcloud.talk.contacts.load -import com.nextcloud.talk.contacts.loadImage -import com.nextcloud.talk.data.database.mappers.asModel -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.models.json.chat.ChatMessageJson -import com.nextcloud.talk.models.json.chat.ReadStatus -import com.nextcloud.talk.models.json.opengraph.Reference -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.DateUtils -import com.nextcloud.talk.utils.DisplayUtils -import com.nextcloud.talk.utils.DrawableUtils.getDrawableResourceIdForMimeType -import com.nextcloud.talk.utils.message.MessageUtils -import com.nextcloud.talk.utils.preview.ComposePreviewUtils -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import org.osmdroid.config.Configuration -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.Marker -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import javax.inject.Inject -import kotlin.random.Random - -@Suppress("FunctionNaming", "TooManyFunctions", "LongMethod", "StaticFieldLeak", "LargeClass") -class ComposeChatAdapter( - private var messagesJson: List? = null, - private var messageId: String? = null, - private var threadId: String? = null, - private val utils: ComposePreviewUtils? = null -) { - - interface PreviewAble { - val viewThemeUtils: ViewThemeUtils - val messageUtils: MessageUtils - val contactsViewModel: ContactsViewModel - val chatViewModel: ChatViewModel - val context: Context - val userManager: UserManager - } - - @AutoInjector(NextcloudTalkApplication::class) - inner class ComposeChatAdapterViewModel : - ViewModel(), - PreviewAble { - - @Inject - override lateinit var viewThemeUtils: ViewThemeUtils - - @Inject - override lateinit var messageUtils: MessageUtils - - @Inject - override lateinit var contactsViewModel: ContactsViewModel - - @Inject - override lateinit var chatViewModel: ChatViewModel - - @Inject - override lateinit var context: Context - - @Inject - override lateinit var userManager: UserManager - - init { - sharedApplication?.componentApplication?.inject(this) - } - } - - class ComposeChatAdapterPreviewViewModel( - override val viewThemeUtils: ViewThemeUtils, - override val messageUtils: MessageUtils, - override val contactsViewModel: ContactsViewModel, - override val chatViewModel: ChatViewModel, - override val context: Context, - override val userManager: UserManager - ) : ViewModel(), - PreviewAble - - companion object { - val TAG: String = ComposeChatAdapter::class.java.simpleName - private val REGULAR_TEXT_SIZE = 16.sp - private val TIME_TEXT_SIZE = 12.sp - private val AUTHOR_TEXT_SIZE = 12.sp - private const val LONG_1000 = 1000 - private const val SCROLL_DELAY = 20L - private const val QUOTE_SHAPE_OFFSET = 6 - private const val LINE_SPACING = 1.2f - private const val CAPTION_WEIGHT = 0.8f - private const val DEFAULT_WAVE_SIZE = 50 - private const val MAP_ZOOM = 15.0 - private const val INT_8 = 8 - private const val INT_128 = 128 - private const val ANIMATION_DURATION = 2500L - private const val ANIMATED_BLINK = 500 - private const val FLOAT_06 = 0.6f - private const val HALF_OPACITY = 127 - private const val MESSAGE_LENGTH_THRESHOLD = 25 - } - - private var incomingShape: RoundedCornerShape = RoundedCornerShape(2.dp, 20.dp, 20.dp, 20.dp) - private var outgoingShape: RoundedCornerShape = RoundedCornerShape(20.dp, 2.dp, 20.dp, 20.dp) - - val viewModel: PreviewAble = - if (utils != null) { - ComposeChatAdapterPreviewViewModel( - utils.viewThemeUtils, - utils.messageUtils, - utils.contactsViewModel, - utils.chatViewModel, - utils.context, - utils.userManager - ) - } else { - ComposeChatAdapterViewModel() - } - - val items = mutableStateListOf() - val currentUser: User = viewModel.userManager.currentUser.blockingGet() - val colorScheme = viewModel.viewThemeUtils.getColorScheme(viewModel.context) - val highEmphasisColorInt = if (DisplayUtils.isAppThemeDarkMode(viewModel.context)) { - Color.White.toArgb() - } else { - Color.Black.toArgb() - } - val highEmphasisColor = Color(highEmphasisColorInt) - - fun addMessages(messages: MutableList, append: Boolean) { - if (messages.isEmpty()) return - - val processedMessages = messages.toMutableList() - if (items.isNotEmpty()) { - if (append) { - processedMessages.add(items.first()) - } else { - processedMessages.add(items.last()) - } - } - - if (append) items.addAll(processedMessages) else items.addAll(0, processedMessages) - } - - @Composable - fun GetComposableForMessage(message: ChatMessage, isBlinkingState: MutableState = mutableStateOf(false)) { - message.activeUser = currentUser - when (val type = message.getCalculateMessageType()) { - ChatMessage.MessageType.SYSTEM_MESSAGE -> { - if (!message.shouldFilter()) { - SystemMessage(message) - } - } - - ChatMessage.MessageType.VOICE_MESSAGE -> { - VoiceMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE -> { - ImageMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.SINGLE_NC_GEOLOCATION_MESSAGE -> { - GeolocationMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.POLL_MESSAGE -> { - PollMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.DECK_CARD -> { - DeckMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.REGULAR_TEXT_MESSAGE -> { - if (message.isLinkPreview()) { - LinkMessage(message, isBlinkingState) - } else { - TextMessage(message, isBlinkingState) - } - } - - else -> { - Log.d(TAG, "Unknown message type: $type") - } - } - } - - @OptIn(ExperimentalFoundationApi::class) - @Composable - fun GetView() { - val listState = rememberLazyListState() - val isBlinkingState = remember { mutableStateOf(true) } - - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - state = listState, - modifier = Modifier.padding(16.dp) - ) { - stickyHeader { - if (items.size == 0) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize() - ) { - ShimmerGroup() - } - } else { - val timestamp = items[listState.firstVisibleItemIndex].timestamp - val dateString = formatTime(timestamp * LONG_1000) - val color = highEmphasisColor - val backgroundColor = - LocalResources.current.getColor(R.color.bg_message_list_incoming_bubble, null) - Row( - horizontalArrangement = Arrangement.Absolute.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - dateString, - fontSize = AUTHOR_TEXT_SIZE, - color = color, - modifier = Modifier - .padding(8.dp) - .shadow( - 16.dp, - spotColor = colorScheme.primary, - ambientColor = colorScheme.primary - ) - .background(color = Color(backgroundColor), shape = RoundedCornerShape(8.dp)) - .padding(8.dp) - ) - Spacer(modifier = Modifier.weight(1f)) - } - } - } - - items(items) { message -> - message.incoming = message.actorId != currentUser.userId - GetComposableForMessage(message, isBlinkingState) - } - } - - if (messageId != null && items.size > 0) { - LaunchedEffect(Dispatchers.Main) { - delay(SCROLL_DELAY) - val pos = searchMessages(messageId!!) - if (pos > 0) { - listState.scrollToItem(pos) - } - delay(ANIMATION_DURATION) - isBlinkingState.value = false - } - } - } - - private fun ChatMessage.shouldFilter(): Boolean = - this.isReaction() || - this.isPollVotedMessage() || - this.isEditMessage() || - this.isInfoMessageAboutDeletion() || - this.isThreadCreatedMessage() - - private fun ChatMessage.isInfoMessageAboutDeletion(): Boolean = - this.parentMessageId != null && - this.systemMessageType == ChatMessage.SystemMessageType.MESSAGE_DELETED - - private fun ChatMessage.isPollVotedMessage(): Boolean = - this.systemMessageType == ChatMessage.SystemMessageType.POLL_VOTED - - private fun ChatMessage.isEditMessage(): Boolean = - this.systemMessageType == ChatMessage.SystemMessageType.MESSAGE_EDITED - - private fun ChatMessage.isThreadCreatedMessage(): Boolean = - this.systemMessageType == ChatMessage.SystemMessageType.THREAD_CREATED - - private fun ChatMessage.isReaction(): Boolean = - systemMessageType == ChatMessage.SystemMessageType.REACTION || - systemMessageType == ChatMessage.SystemMessageType.REACTION_DELETED || - systemMessageType == ChatMessage.SystemMessageType.REACTION_REVOKED - - private fun formatTime(timestampMillis: Long): String { - val instant = Instant.ofEpochMilli(timestampMillis) - val dateTime = instant.atZone(ZoneId.systemDefault()).toLocalDate() - val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy") - return dateTime.format(formatter) - } - - private fun searchMessages(searchId: String): Int { - items.forEachIndexed { index, message -> - if (message.id == searchId) return index - } - return -1 - } - - @Composable - private fun CommonMessageQuote(context: Context, message: ChatMessage) { - val color = colorResource(R.color.high_emphasis_text) - Row( - modifier = Modifier - .drawWithCache { - onDrawWithContent { - drawLine( - color = color, - start = Offset(0f, this.size.height / QUOTE_SHAPE_OFFSET), - end = Offset(0f, this.size.height - (this.size.height / QUOTE_SHAPE_OFFSET)), - strokeWidth = 4f, - cap = StrokeCap.Round - ) - - drawContent() - } - } - .padding(8.dp) - ) { - Column { - Text(message.actorDisplayName!!, fontSize = AUTHOR_TEXT_SIZE) - val imageUri = message.imageUrl - if (imageUri != null) { - val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image - val loadedImage = loadImage(imageUri, context, errorPlaceholderImage) - AsyncImage( - model = loadedImage, - contentDescription = stringResource(R.string.nc_sent_an_image), - modifier = Modifier - .padding(8.dp) - .fillMaxHeight() - ) - } - EnrichedText(message) - } - } - } - - @Composable - private fun CommonMessageBody( - message: ChatMessage, - includePadding: Boolean = true, - playAnimation: Boolean = false, - content: @Composable () -> Unit - ) { - fun shouldShowTimeNextToContent(message: ChatMessage): Boolean { - val containsLinebreak = message.message?.contains("\n") ?: false || - message.message?.contains("\r") ?: false - - return ((message.message?.length ?: 0) < MESSAGE_LENGTH_THRESHOLD) && - !isFirstMessageOfThreadInNormalChat(message) && - message.messageParameters.isNullOrEmpty() && - !containsLinebreak - } - - val incoming = message.incoming - val color = if (incoming) { - if (message.isDeleted) { - getColorFromTheme(LocalContext.current, R.color.bg_message_list_incoming_bubble_deleted) - } else { - getColorFromTheme(LocalContext.current, R.color.bg_message_list_incoming_bubble) - } - } else { - val outgoingBubbleColor = viewModel.viewThemeUtils.talk - .getOutgoingMessageBubbleColor(LocalContext.current, message.isDeleted, false) - - if (message.isDeleted) { - ColorUtils.setAlphaComponent(outgoingBubbleColor, HALF_OPACITY) - } else { - outgoingBubbleColor - } - } - - val shape = if (incoming) incomingShape else outgoingShape - - val rowModifier = if (message.id == messageId && playAnimation) { - Modifier.withCustomAnimation(incoming) - } else { - Modifier - } - - Row( - modifier = rowModifier.fillMaxWidth(), - horizontalArrangement = if (incoming) Arrangement.Start else Arrangement.End - ) { - if (incoming) { - val imageUri = message.actorId?.let { viewModel.contactsViewModel.getImageUri(it, true) } - val errorPlaceholderImage: Int = R.drawable.account_circle_96dp - val loadedImage = loadImage(imageUri, LocalContext.current, errorPlaceholderImage) - AsyncImage( - model = loadedImage, - contentDescription = stringResource(R.string.user_avatar), - modifier = Modifier - .size(48.dp) - .align(Alignment.CenterVertically) - .padding(end = 8.dp) - ) - } else { - Spacer(Modifier.width(8.dp)) - } - - Surface( - modifier = Modifier - .defaultMinSize(60.dp, 40.dp) - .widthIn(60.dp, 280.dp) - .heightIn(40.dp, 450.dp), - color = Color(color), - shape = shape - ) { - val modifier = if (includePadding) { - Modifier.padding(16.dp, 4.dp, 16.dp, 4.dp) - } else { - Modifier - } - - Column(modifier = modifier) { - if (messagesJson != null && - message.parentMessageId != null && - !message.isDeleted && - message.parentMessageId.toString() != threadId - ) { - messagesJson!! - .find { it.parentMessage?.id == message.parentMessageId } - ?.parentMessage!!.asModel() - .let { CommonMessageQuote(LocalContext.current, it) } - } - - if (incoming) { - Text( - message.actorDisplayName.toString(), - fontSize = AUTHOR_TEXT_SIZE, - color = highEmphasisColor - ) - } - - ThreadTitle(message) - - if (shouldShowTimeNextToContent(message)) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - content() - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 6.dp, start = 8.dp) - ) { - TimeDisplay(message) - ReadStatus(message) - } - } - } else { - content() - Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically - ) { - TimeDisplay(message) - ReadStatus(message) - } - } - } - } - } - } - - private fun getColorFromTheme(context: Context, resourceId: Int): Int { - val isDarkMode = DisplayUtils.isAppThemeDarkMode(context) - val nightConfig = android.content.res.Configuration() - nightConfig.uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES - val nightContext = context.createConfigurationContext(nightConfig) - - return if (isDarkMode) { - nightContext.getColor(resourceId) - } else { - context.getColor(resourceId) - } - } - - @Composable - private fun TimeDisplay(message: ChatMessage) { - val timeString = DateUtils(LocalContext.current) - .getLocalTimeStringFromTimestamp(message.timestamp) - Text( - timeString, - fontSize = TIME_TEXT_SIZE, - textAlign = TextAlign.Center, - color = highEmphasisColor - ) - } - - @Composable - private fun ReadStatus(message: ChatMessage) { - if (message.readStatus == ReadStatus.NONE) { - val read = painterResource(R.drawable.ic_check_all) - Icon( - read, - "", - modifier = Modifier - .padding(start = 4.dp) - .size(16.dp), - tint = highEmphasisColor - ) - } - } - - @Composable - private fun ThreadTitle(message: ChatMessage) { - if (isFirstMessageOfThreadInNormalChat(message)) { - Row { - val read = painterResource(R.drawable.outline_forum_24) - Icon( - read, - "", - modifier = Modifier - .padding(end = 6.dp) - .size(18.dp) - .align(Alignment.CenterVertically) - ) - Text( - text = message.threadTitle ?: "", - fontSize = REGULAR_TEXT_SIZE, - fontWeight = FontWeight.SemiBold - ) - } - } - } - - fun isFirstMessageOfThreadInNormalChat(message: ChatMessage): Boolean = threadId == null && message.isThread - - @Composable - private fun Modifier.withCustomAnimation(incoming: Boolean): Modifier { - val infiniteTransition = rememberInfiniteTransition() - val borderColor by infiniteTransition.animateColor( - initialValue = colorScheme.primary, - targetValue = colorScheme.background, - animationSpec = infiniteRepeatable( - animation = tween(ANIMATED_BLINK, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ) - ) - - return this.border( - width = 4.dp, - color = borderColor, - shape = if (incoming) incomingShape else outgoingShape - ) - } - - @Composable - private fun ShimmerGroup() { - Shimmer() - Shimmer(true) - Shimmer() - Shimmer(true) - Shimmer(true) - Shimmer() - Shimmer(true) - } - - @Composable - private fun Shimmer(outgoing: Boolean = false) { - Row(modifier = Modifier.padding(top = 16.dp)) { - if (!outgoing) { - ShimmerImage(this) - } - - val v1 by remember { mutableIntStateOf((INT_8..INT_128).random()) } - val v2 by remember { mutableIntStateOf((INT_8..INT_128).random()) } - val v3 by remember { mutableIntStateOf((INT_8..INT_128).random()) } - - Column { - ShimmerText(this, v1, outgoing) - ShimmerText(this, v2, outgoing) - ShimmerText(this, v3, outgoing) - } - } - } - - @Composable - private fun ShimmerImage(rowScope: RowScope) { - rowScope.apply { - AndroidView( - factory = { ctx -> - LoaderImageView(ctx).apply { - layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) - val color = resources.getColor(R.color.nc_shimmer_default_color, null) - setBackgroundColor(color) - } - }, - modifier = Modifier - .clip(CircleShape) - .size(40.dp) - .align(Alignment.Top) - ) - } - } - - @Composable - private fun ShimmerText(columnScope: ColumnScope, margin: Int, outgoing: Boolean = false) { - columnScope.apply { - AndroidView( - factory = { ctx -> - LoaderTextView(ctx).apply { - layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - val color = if (outgoing) { - colorScheme.primary.toArgb() - } else { - resources.getColor(R.color.nc_shimmer_default_color, null) - } - - setBackgroundColor(color) - } - }, - modifier = Modifier.padding( - top = 6.dp, - end = if (!outgoing) margin.dp else 8.dp, - start = if (outgoing) margin.dp else 8.dp - ) - ) - } - } - - @Composable - private fun EnrichedText(message: ChatMessage) { - AndroidView(factory = { ctx -> - val incoming = message.actorId != currentUser.userId - var processedMessageText = viewModel.messageUtils.enrichChatMessageText( - ctx, - message, - incoming, - viewModel.viewThemeUtils - ) - - processedMessageText = viewModel.messageUtils.processMessageParameters( - ctx, - viewModel.viewThemeUtils, - processedMessageText!!, - message, - null - ) - - EmojiTextView(ctx).apply { - layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) - setLineSpacing(0F, LINE_SPACING) - textAlignment = TEXT_ALIGNMENT_VIEW_START - text = processedMessageText - setPadding(0, INT_8, 0, 0) - } - }, modifier = Modifier) - } - - @Composable - private fun TextMessage(message: ChatMessage, state: MutableState) { - CommonMessageBody(message, playAnimation = state.value) { - EnrichedText(message) - } - } - - @Composable - fun SystemMessage(message: ChatMessage) { - val similarMessages = sharedApplication!!.resources.getQuantityString( - R.plurals.see_similar_system_messages, - message.expandableChildrenAmount, - message.expandableChildrenAmount - ) - Column(horizontalAlignment = Alignment.CenterHorizontally) { - val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) - Row(horizontalArrangement = Arrangement.Absolute.Center, verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Modifier.weight(1f)) - Text( - message.text, - fontSize = AUTHOR_TEXT_SIZE, - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(FLOAT_06) - ) - Text( - timeString, - fontSize = TIME_TEXT_SIZE, - textAlign = TextAlign.End, - modifier = Modifier.align(Alignment.CenterVertically) - ) - Spacer(modifier = Modifier.weight(1f)) - } - - if (message.expandableChildrenAmount > 0) { - TextButtonNoStyling(similarMessages) { - // NOTE: Read only for now - } - } - } - } - - @Composable - private fun TextButtonNoStyling(text: String, onClick: () -> Unit) { - TextButton(onClick = onClick) { - Text( - text, - fontSize = AUTHOR_TEXT_SIZE, - color = highEmphasisColor - ) - } - } - - @Composable - private fun ImageMessage(message: ChatMessage, state: MutableState) { - val hasCaption = (message.message != "{file}") - val incoming = message.actorId != currentUser.userId - val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) - CommonMessageBody(message, includePadding = false, playAnimation = state.value) { - Column { - message.activeUser = currentUser - val imageUri = message.imageUrl - val mimetype = message.selectedIndividualHashMap!![KEY_MIMETYPE] - val drawableResourceId = getDrawableResourceIdForMimeType(mimetype) - val loadedImage = load(imageUri, LocalContext.current, drawableResourceId) - - AsyncImage( - model = loadedImage, - contentDescription = stringResource(R.string.nc_sent_an_image), - modifier = Modifier - .fillMaxWidth(), - contentScale = ContentScale.FillWidth - ) - - if (hasCaption) { - Text( - message.text, - fontSize = 12.sp, - modifier = Modifier - .widthIn(20.dp, 140.dp) - .padding(8.dp) - ) - } - } - } - - if (!hasCaption) { - Row(modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { - if (!incoming) { - Spacer(Modifier.weight(1f)) - } else { - Spacer(Modifier.size(width = 56.dp, 0.dp)) // To account for avatar size - } - Text(message.text, fontSize = 12.sp) - Text( - timeString, - fontSize = TIME_TEXT_SIZE, - textAlign = TextAlign.End, - modifier = Modifier - .align(Alignment.CenterVertically) - .padding() - .padding(start = 4.dp) - ) - if (message.readStatus == ReadStatus.NONE) { - val read = painterResource(R.drawable.ic_check_all) - Icon( - read, - "", - modifier = Modifier - .padding(start = 4.dp) - .size(16.dp) - .align(Alignment.CenterVertically) - ) - } - } - } - } - - @Composable - private fun VoiceMessage(message: ChatMessage, state: MutableState) { - CommonMessageBody(message, playAnimation = state.value) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Filled.PlayArrow, - contentDescription = "play", - modifier = Modifier.size(24.dp) - ) - - AndroidView( - factory = { ctx -> - WaveformSeekBar(ctx).apply { - setWaveData(FloatArray(DEFAULT_WAVE_SIZE) { Random.nextFloat() }) // READ ONLY for now - setColors( - colorScheme.inversePrimary.toArgb(), - colorScheme.onPrimaryContainer.toArgb() - ) - } - }, - modifier = Modifier - .width(180.dp) - .height(80.dp) - ) - } - } - } - - @Composable - private fun GeolocationMessage(message: ChatMessage, state: MutableState) { - CommonMessageBody(message, playAnimation = state.value) { - Column { - if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { - for (key in message.messageParameters!!.keys) { - val individualHashMap: Map = message.messageParameters!![key]!! - if (individualHashMap["type"] == "geo-location") { - val lat = individualHashMap["latitude"] - val lng = individualHashMap["longitude"] - - if (lat != null && lng != null) { - val latitude = lat.toDouble() - val longitude = lng.toDouble() - OpenStreetMap(latitude, longitude) - } - } - } - } - } - } - } - - @Composable - private fun OpenStreetMap(latitude: Double, longitude: Double) { - AndroidView( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(), - factory = { context -> - Configuration.getInstance().userAgentValue = context.packageName - MapView(context).apply { - setTileSource(TileSourceFactory.MAPNIK) - setMultiTouchControls(true) - - val geoPoint = GeoPoint(latitude, longitude) - controller.setCenter(geoPoint) - controller.setZoom(MAP_ZOOM) - - val marker = Marker(this) - marker.position = geoPoint - marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - marker.title = "Location" - overlays.add(marker) - - invalidate() - } - }, - update = { mapView -> - val geoPoint = GeoPoint(latitude, longitude) - mapView.controller.setCenter(geoPoint) - - val marker = mapView.overlays.find { it is Marker } as? Marker - marker?.position = geoPoint - mapView.invalidate() - } - ) - } - - @Composable - private fun LinkMessage(message: ChatMessage, state: MutableState) { - val color = colorResource(R.color.high_emphasis_text) - viewModel.chatViewModel.getOpenGraph( - currentUser.getCredentials(), - currentUser.baseUrl!!, - message.extractedUrlToPreview!! - ) - CommonMessageBody(message, playAnimation = state.value) { - EnrichedText(message) - Row( - modifier = Modifier - .drawWithCache { - onDrawWithContent { - drawLine( - color = color, - start = Offset.Zero, - end = Offset(0f, this.size.height), - strokeWidth = 4f, - cap = StrokeCap.Round - ) - - drawContent() - } - } - .padding(8.dp) - .padding(4.dp) - ) { - Column { - val graphObject = viewModel.chatViewModel.getOpenGraph.asFlow().collectAsState( - Reference( - // Dummy class - ) - ).value.openGraphObject - graphObject?.let { - Text(it.name, fontSize = REGULAR_TEXT_SIZE, fontWeight = FontWeight.Bold) - it.description?.let { Text(it, fontSize = AUTHOR_TEXT_SIZE) } - it.link?.let { Text(it, fontSize = TIME_TEXT_SIZE) } - it.thumb?.let { - val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image - val loadedImage = load(it, LocalContext.current, errorPlaceholderImage) - AsyncImage( - model = loadedImage, - contentDescription = stringResource(R.string.nc_sent_an_image), - modifier = Modifier - .height(120.dp) - ) - } - } - } - } - } - } - - @Composable - private fun PollMessage(message: ChatMessage, state: MutableState) { - CommonMessageBody(message, playAnimation = state.value) { - Column { - if (message.messageParameters != null && message.messageParameters!!.size > 0) { - for (key in message.messageParameters!!.keys) { - val individualHashMap: Map = message.messageParameters!![key]!! - if (individualHashMap["type"] == "talk-poll") { - // val pollId = individualHashMap["id"] - val pollName = individualHashMap["name"].toString() - Row(modifier = Modifier.padding(start = 8.dp)) { - Icon(painterResource(R.drawable.ic_baseline_bar_chart_24), "") - Text(pollName, fontSize = AUTHOR_TEXT_SIZE, fontWeight = FontWeight.Bold) - } - - TextButtonNoStyling(stringResource(R.string.message_poll_tap_to_open)) { - // NOTE: read only for now - } - } - } - } - } - } - } - - @Composable - private fun DeckMessage(message: ChatMessage, state: MutableState) { - CommonMessageBody(message, playAnimation = state.value) { - Column { - if (message.messageParameters != null && message.messageParameters!!.size > 0) { - for (key in message.messageParameters!!.keys) { - val individualHashMap: Map = message.messageParameters!![key]!! - if (individualHashMap["type"] == "deck-card") { - val cardName = individualHashMap["name"] - val stackName = individualHashMap["stackname"] - val boardName = individualHashMap["boardname"] - // val cardLink = individualHashMap["link"] - - if (cardName?.isNotEmpty() == true) { - val cardDescription = String.format( - LocalContext.current.resources.getString(R.string.deck_card_description), - stackName, - boardName - ) - Row(modifier = Modifier.padding(start = 8.dp)) { - Icon(painterResource(R.drawable.deck), "") - Text(cardName, fontSize = AUTHOR_TEXT_SIZE, fontWeight = FontWeight.Bold) - } - Text(cardDescription, fontSize = AUTHOR_TEXT_SIZE) - } - } - } - } - } - } - } -} - -@Preview(showBackground = true, widthDp = 380, heightDp = 800) -@Composable -@Suppress("MagicNumber", "LongMethod") -fun AllMessageTypesPreview() { - val previewUtils = ComposePreviewUtils.getInstance(LocalContext.current) - val adapter = remember { - ComposeChatAdapter( - messagesJson = null, - messageId = null, - threadId = null, - previewUtils - ) - } - - val sampleMessages = remember { - listOf( - // Text Messages - ChatMessage().apply { - jsonMessageId = 1 - actorId = "user1" - message = "I love Nextcloud" - timestamp = System.currentTimeMillis() - actorDisplayName = "User1" - messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name - }, - ChatMessage().apply { - jsonMessageId = 2 - actorId = "user1_id" - message = "I love Nextcloud" - timestamp = System.currentTimeMillis() - actorDisplayName = "User2" - messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name - }, - ChatMessage().apply { - jsonMessageId = 3 - actorId = "user1_id" - message = "This is a really really really really really really really really really long message" - timestamp = System.currentTimeMillis() - actorDisplayName = "User2" - messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name - }, - ChatMessage().apply { - jsonMessageId = 4 - actorId = "user1_id" - message = "some \n linebreak" - timestamp = System.currentTimeMillis() - actorDisplayName = "User2" - messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name - }, - ChatMessage().apply { - jsonMessageId = 5 - actorId = "user1_id" - threadTitle = "Thread title" - isThread = true - message = "Content of a first thread message" - timestamp = System.currentTimeMillis() - actorDisplayName = "User2" - messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name - }, - ChatMessage().apply { - jsonMessageId = 6 - actorId = "user1_id" - threadTitle = "looooooooooooong Thread title" - isThread = true - message = "Content" - timestamp = System.currentTimeMillis() - actorDisplayName = "User2" - messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name - } - ) - } - - LaunchedEffect(sampleMessages) { - // Use LaunchedEffect or similar to update state once - if (adapter.items.isEmpty()) { - // Prevent adding multiple times on recomposition - adapter.addMessages(sampleMessages.toMutableList(), append = false) // Add messages - } - } - - MaterialTheme(colorScheme = adapter.colorScheme) { - // Use the (potentially faked) color scheme - Box(modifier = Modifier.fillMaxSize()) { - // Provide a container - adapter.GetView() // Call the main Composable - } - } -} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt new file mode 100644 index 00000000000..cd612ce44b5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt @@ -0,0 +1,379 @@ +package com.nextcloud.talk.ui.chat + +import android.content.Context +import android.view.View +import android.widget.LinearLayout +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.graphics.ColorUtils +import androidx.emoji2.widget.EmojiTextView +import coil.compose.AsyncImage +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageStatusIcon +import com.nextcloud.talk.contacts.loadImage +import com.nextcloud.talk.ui.theme.LocalMessageUtils +import com.nextcloud.talk.ui.theme.LocalViewThemeUtils +import com.nextcloud.talk.utils.DateUtils +import com.nextcloud.talk.utils.DisplayUtils + +private val REGULAR_TEXT_SIZE = 16.sp +private val TIME_TEXT_SIZE = 12.sp +private val AUTHOR_TEXT_SIZE = 12.sp +private const val QUOTE_SHAPE_OFFSET = 6 +private const val LINE_SPACING = 1.2f +private const val INT_8 = 8 +private const val HALF_OPACITY = 127 +private const val MESSAGE_LENGTH_THRESHOLD = 25 +private const val ANIMATED_BLINK = 500 + +@Composable +fun MessageScaffold( + uiMessage: ChatMessageUi, + conversationThreadId: Long? = null, + includePadding: Boolean = true, + showAvatar: Boolean = true, + playAnimation: Boolean = false, + content: @Composable () -> Unit +) { + fun shouldShowTimeNextToContent(message: ChatMessageUi): Boolean { + val containsLinebreak = message.message.contains("\n") ?: false || + message.message.contains("\r") ?: false + + return ((message.message.length ?: 0) < MESSAGE_LENGTH_THRESHOLD) && + !isFirstMessageOfThreadInNormalChat(message, conversationThreadId) && + // message.messageParameters.isNullOrEmpty() && + !containsLinebreak + } + + val incoming = uiMessage.incoming + val color = if (incoming) { + if (uiMessage.isDeleted) { + getColorFromTheme(LocalContext.current, R.color.bg_message_list_incoming_bubble_deleted) + } else { + getColorFromTheme(LocalContext.current, R.color.bg_message_list_incoming_bubble) + } + } else { + val viewThemeUtils = LocalViewThemeUtils.current + + val outgoingBubbleColor = viewThemeUtils.talk + .getOutgoingMessageBubbleColor(LocalContext.current, uiMessage.isDeleted, false) + + if (uiMessage.isDeleted) { + ColorUtils.setAlphaComponent(outgoingBubbleColor, HALF_OPACITY) + } else { + outgoingBubbleColor + } + } + + val shape = if (incoming) { + RoundedCornerShape( + 2.dp, + 20.dp, + 20.dp, + 20.dp + ) + } else { + RoundedCornerShape(20.dp, 2.dp, 20.dp, 20.dp) + } + + val rowModifier = Modifier + // val rowModifier = if (message.id == messageId && playAnimation) { + // Modifier.withCustomAnimation(incoming, shape) + // } else { + // Modifier + // } + + Row( + modifier = rowModifier.fillMaxWidth(), + horizontalArrangement = if (incoming) Arrangement.Start else Arrangement.End + ) { + if (incoming && showAvatar) { + val errorPlaceholderImage: Int = R.drawable.account_circle_96dp + val loadedImage = loadImage(uiMessage.avatarUrl, LocalContext.current, errorPlaceholderImage) + AsyncImage( + model = loadedImage, + contentDescription = stringResource(R.string.user_avatar), + modifier = Modifier + .size(48.dp) + .align(Alignment.CenterVertically) + .padding(end = 8.dp) + ) + } else { + Spacer(Modifier.width(8.dp)) + } + + Surface( + modifier = Modifier + .defaultMinSize(60.dp, 40.dp) + .widthIn(60.dp, 280.dp) + .heightIn(40.dp, 450.dp), + color = Color(color), + shape = shape + ) { + val modifier = if (includePadding) { + Modifier.padding(16.dp, 4.dp, 16.dp, 4.dp) + } else { + Modifier + } + + Column(modifier = modifier) { + uiMessage.parentMessage?.let { + CommonMessageQuote( + LocalContext.current, + it, + incoming + ) + } + + if (incoming) { + Text( + uiMessage.actorDisplayName, + fontSize = AUTHOR_TEXT_SIZE, + color = colorScheme.onSurfaceVariant + ) + } + + ThreadTitle(uiMessage) + + if (shouldShowTimeNextToContent(uiMessage)) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + content() + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 6.dp, start = 8.dp) + ) { + TimeDisplay(uiMessage) + ReadStatus(uiMessage) + } + } + } else { + content() + Row( + modifier = Modifier.align(Alignment.End), + verticalAlignment = Alignment.CenterVertically + ) { + TimeDisplay(uiMessage) + ReadStatus(uiMessage) + } + } + } + } + } +} + +@Composable +fun CommonMessageQuote( + context: Context, + message: ChatMessageUi, + incoming: Boolean +) { + val color = colorResource(R.color.high_emphasis_text) + Row( + modifier = Modifier + .drawWithCache { + onDrawWithContent { + drawLine( + color = color, + start = Offset(0f, this.size.height / QUOTE_SHAPE_OFFSET), + end = Offset(0f, this.size.height - (this.size.height / QUOTE_SHAPE_OFFSET)), + strokeWidth = 4f, + cap = StrokeCap.Round + ) + + drawContent() + } + } + .padding(8.dp) + ) { + Column { + Text(message.actorDisplayName!!, fontSize = AUTHOR_TEXT_SIZE) + // val imageUri = message.imageUrl + // if (imageUri != null) { + // val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image + // val loadedImage = loadImage(imageUri, context, errorPlaceholderImage) + // AsyncImage( + // model = loadedImage, + // contentDescription = stringResource(R.string.nc_sent_an_image), + // modifier = Modifier + // .padding(8.dp) + // .fillMaxHeight() + // ) + // } + + EnrichedText( + message + ) + } + } +} + +private fun getColorFromTheme(context: Context, resourceId: Int): Int { + val isDarkMode = DisplayUtils.isAppThemeDarkMode(context) + val nightConfig = android.content.res.Configuration() + nightConfig.uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES + val nightContext = context.createConfigurationContext(nightConfig) + + return if (isDarkMode) { + nightContext.getColor(resourceId) + } else { + context.getColor(resourceId) + } +} + +@Composable +fun TimeDisplay(message: ChatMessageUi) { + val timeString = DateUtils(LocalContext.current) + .getLocalTimeStringFromTimestamp(message.timestamp) + Text( + timeString, + fontSize = TIME_TEXT_SIZE, + textAlign = TextAlign.Center, + color = colorScheme.onSurfaceVariant + ) +} + +@Composable +fun ReadStatus(message: ChatMessageUi) { + val icon = when (message.statusIcon) { + MessageStatusIcon.FAILED -> painterResource(R.drawable.baseline_error_outline_24) + MessageStatusIcon.SENDING -> painterResource(R.drawable.baseline_schedule_24) + MessageStatusIcon.READ -> painterResource(R.drawable.ic_check_all) + MessageStatusIcon.SENT -> painterResource(R.drawable.ic_check) + } + + Icon( + painter = icon, + contentDescription = "", + modifier = Modifier + .padding(start = 4.dp) + .size(16.dp), + tint = colorScheme.onSurfaceVariant + ) +} + +@Composable +fun ThreadTitle(message: ChatMessageUi) { + if (isFirstMessageOfThreadInNormalChat(message)) { + Row { + val read = painterResource(R.drawable.outline_forum_24) + Icon( + read, + "", + modifier = Modifier + .padding(end = 6.dp) + .size(18.dp) + .align(Alignment.CenterVertically) + ) + Text( + text = message.threadTitle ?: "", + fontSize = REGULAR_TEXT_SIZE, + fontWeight = FontWeight.SemiBold + ) + } + } +} + +@Composable +fun EnrichedText(message: ChatMessageUi) { + val viewThemeUtils = LocalViewThemeUtils.current + val messageUtils = LocalMessageUtils.current + + AndroidView(factory = { ctx -> + var processedMessageText = messageUtils.enrichChatMessageUiText( + context = ctx, + message = message, + incoming = message.incoming, + viewThemeUtils = viewThemeUtils + ) + + // Here it gets difficult! we need to change the handling of messageParameters! + // for now, processMessageParameters is commented out and processedMessageText is not further processed + + // processedMessageText = messageUtils.processMessageParameters( + // themingContext = ctx, + // viewThemeUtils = viewThemeUtils, + // spannedText = processedMessageText!!, + // message = message, + // itemView = null + // ) + + EmojiTextView(ctx).apply { + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + setLineSpacing(0F, LINE_SPACING) + textAlignment = View.TEXT_ALIGNMENT_VIEW_START + // text = processedMessageText + // added messageId just for debugging + text = "" + processedMessageText + " (" + message.id + ")" + + setPadding(0, INT_8, 0, 0) + } + }, modifier = Modifier) +} + +fun isFirstMessageOfThreadInNormalChat(message: ChatMessageUi, conversationThreadId: Long? = null): Boolean = + conversationThreadId == null && message.isThread + +@Composable +private fun Modifier.withCustomAnimation(incoming: Boolean, shape: RoundedCornerShape): Modifier { + val infiniteTransition = rememberInfiniteTransition() + val borderColor by infiniteTransition.animateColor( + initialValue = colorScheme.primary, + targetValue = colorScheme.background, + animationSpec = infiniteRepeatable( + animation = tween(ANIMATED_BLINK, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ) + ) + + return this.border( + width = 4.dp, + color = borderColor, + shape = shape + ) +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt new file mode 100644 index 00000000000..839f1ede723 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import android.util.Log +import androidx.compose.runtime.Composable +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageTypeContent + +@Composable +fun ChatMessageView( + message: ChatMessageUi, + showAvatar: Boolean, + conversationThreadId: Long? = null +) { + when (val content = message.content) { + MessageTypeContent.RegularText -> { + TextMessage( + uiMessage = message, + showAvatar = showAvatar, + conversationThreadId = conversationThreadId + ) + } + + MessageTypeContent.SystemMessage -> { + SystemMessage(message) + } + + is MessageTypeContent.Image -> { + ImageMessage( + typeContent = content, + message = message, + conversationThreadId = conversationThreadId + ) + } + + is MessageTypeContent.LinkPreview -> { + LinkMessage( + typeContent = content, + message = message, + conversationThreadId = conversationThreadId + ) + } + + is MessageTypeContent.Geolocation -> { + GeolocationMessage( + typeContent = content, + message = message + ) + } + + is MessageTypeContent.Voice -> { + VoiceMessage( + typeContent = content, + message = message, + conversationThreadId = conversationThreadId + ) + } + + is MessageTypeContent.Poll -> { + PollMessage( + typeContent = content, + message = message, + conversationThreadId = conversationThreadId + ) + } + + is MessageTypeContent.Deck -> { + DeckMessage( + typeContent = content, + message = message, + conversationThreadId = conversationThreadId + ) + } + + else -> { + Log.d("ChatView", "Unknown message type: ${'$'}type") + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt new file mode 100644 index 00000000000..b1fc1ba1867 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -0,0 +1,406 @@ +package com.nextcloud.talk.ui.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextcloud.talk.chat.viewmodels.ChatViewModel +import com.nextcloud.talk.ui.theme.LocalViewThemeUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private const val LONG_1000 = 1000L +private val AUTHOR_TEXT_SIZE = 12.sp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GetNewChatView( + chatItems: List, + showAvatar: Boolean, + conversationThreadId: Long? = null, + onLoadMore: (() -> Unit?)?, + advanceLocalLastReadMessageIfNeeded: ((Int) -> Unit?)?, + updateRemoteLastReadMessageIfNeeded: (() -> Unit?)? +) { + val viewThemeUtils = LocalViewThemeUtils.current + val colorScheme = viewThemeUtils.getColorScheme(LocalContext.current) + + val listState = rememberLazyListState() + val showUnreadPopup = remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + val lastNewestIdRef = remember { + object { + var value: Int? = null + } + } + + // Track unread messages count. + var unreadCount by remember { mutableIntStateOf(0) } + + // Determine if user is at newest message + val isAtNewest by remember(listState) { + derivedStateOf { + listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset == 0 + } + } + + val isNearNewest by remember(listState) { + derivedStateOf { + listState.firstVisibleItemIndex <= 2 + } + } + + // Show floating scroll-to-newest button when not at newest + val showScrollToNewest by remember { derivedStateOf { !isAtNewest } } + + val latestChatItems by rememberUpdatedState(chatItems) + + // Track newest message and show unread popup + LaunchedEffect(chatItems) { + if (chatItems.isEmpty()) return@LaunchedEffect + + val newestId = chatItems.firstNotNullOfOrNull { it.messageOrNull()?.id } + val previousNewestId = lastNewestIdRef.value + + val isNearBottom = listState.firstVisibleItemIndex <= 2 + val hasNewMessage = previousNewestId != null && newestId != previousNewestId + + if (hasNewMessage) { + if (isNearBottom) { + listState.animateScrollToItem(0) + unreadCount = 0 + } else { + unreadCount++ + showUnreadPopup.value = true + } + } + + lastNewestIdRef.value = newestId + } + + // Hide unread popup when user scrolls to newest + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .map { it <= 2 } + .distinctUntilChanged() + .collect { nearBottom -> + if (nearBottom) { + showUnreadPopup.value = false + unreadCount = 0 + } + } + } + + // Load more when near end + LaunchedEffect(listState, chatItems.size) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val total = layoutInfo.totalItemsCount + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + lastVisible to total + } + .distinctUntilChanged() + .collect { (lastVisible, total) -> + if (total == 0) return@collect + + val buffer = 5 + val shouldLoadMore = lastVisible >= (total - 1 - buffer) + + if (shouldLoadMore) { + onLoadMore?.invoke() + } + } + } + + // Sticky date header + val stickyDateHeaderText by remember(listState, chatItems) { + derivedStateOf { + chatItems.getOrNull( + listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + )?.let { item -> + when (item) { + is ChatViewModel.ChatItem.MessageItem -> + formatTime(item.uiMessage.timestamp * LONG_1000) + + is ChatViewModel.ChatItem.DateHeaderItem -> + formatTime(item.date) + + is ChatViewModel.ChatItem.UnreadMessagesMarkerItem -> + formatTime(item.date) + } + } ?: "" + } + } + + var stickyDateHeader by remember { mutableStateOf(false) } + + LaunchedEffect(listState, isNearNewest) { + // Only listen to scroll if user is away from newest messages. This ensures the stickyHeader is not shown on + // every new received message when being at the bottom of the list (because this triggers a scroll). + if (!isNearNewest) { + updateRemoteLastReadMessageIfNeeded?.invoke() + snapshotFlow { listState.isScrollInProgress } + .collectLatest { scrolling -> + if (scrolling) { + stickyDateHeader = true + } else { + delay(1200) + stickyDateHeader = false + } + } + } else { + stickyDateHeader = false + } + } + + LaunchedEffect(isAtNewest) { + if (!isAtNewest) return@LaunchedEffect + + latestChatItems + .getOrNull(listState.firstVisibleItemIndex) + ?.let { item -> + // It might not always be a chat message. Not calling advanceLocalLastReadMessageIfNeeded should not + // matter. This should be triggered often enough so it's okay when it's true the next times. + if (item is ChatViewModel.ChatItem.MessageItem) { + advanceLocalLastReadMessageIfNeeded?.invoke(item.uiMessage.id) + } + } + } + + val stickyDateHeaderAlpha by animateFloatAsState( + targetValue = if (stickyDateHeader && stickyDateHeaderText.isNotEmpty()) 1f else 0f, + animationSpec = tween(durationMillis = if (stickyDateHeader) 500 else 1000), + label = "" + ) + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + reverseLayout = true, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(bottom = 20.dp), + modifier = Modifier + .padding(start = 12.dp, end = 12.dp) + .fillMaxSize() + ) { + items(chatItems, key = { it.stableKey() }) { chatItem -> + when (chatItem) { + is ChatViewModel.ChatItem.MessageItem -> { + ChatMessageView( + message = chatItem.uiMessage, + showAvatar = showAvatar, + conversationThreadId = conversationThreadId + ) + } + + is ChatViewModel.ChatItem.DateHeaderItem -> { + DateHeader(chatItem.date) + } + + is ChatViewModel.ChatItem.UnreadMessagesMarkerItem -> { + UnreadMessagesMarker() + } + } + } + } + + // Sticky date header + Surface( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 12.dp) + .alpha(stickyDateHeaderAlpha), + shape = RoundedCornerShape(16.dp), + color = colorScheme.secondaryContainer, + tonalElevation = 2.dp + ) { + Text( + stickyDateHeaderText, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + color = colorScheme.onSecondaryContainer + ) + } + + // Unread messages popup + if (showUnreadPopup.value) { + UnreadMessagesPopup( + unreadCount = unreadCount, + onClick = { + coroutineScope.launch { listState.scrollToItem(0) } + unreadCount = 0 + showUnreadPopup.value = false + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 20.dp) + ) + } + + // Floating scroll-to-newest button + AnimatedVisibility( + visible = showScrollToNewest, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 24.dp), + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut() + ) { + Surface( + onClick = { + coroutineScope.launch { listState.scrollToItem(0) } + unreadCount = 0 + }, + shape = CircleShape, + color = colorScheme.surface.copy(alpha = 0.9f), + tonalElevation = 2.dp + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Scroll to newest", + modifier = Modifier + .size(36.dp) + .padding(8.dp), + tint = colorScheme.onSurface.copy(alpha = 0.9f) + ) + } + } + } +} + +@Composable +fun UnreadMessagesPopup(unreadCount: Int, onClick: () -> Unit, modifier: Modifier = Modifier) { + val viewThemeUtils = LocalViewThemeUtils.current + val colorScheme = viewThemeUtils.getColorScheme(LocalContext.current) + + Surface( + onClick = onClick, + shape = RoundedCornerShape(20.dp), + color = colorScheme.secondaryContainer, + tonalElevation = 3.dp, + modifier = modifier + ) { + Text( + text = "$unreadCount new message${if (unreadCount > 1) "s" else ""}", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + color = colorScheme.onSecondaryContainer + ) + } +} + +@Composable +fun DateHeader(date: LocalDate) { + val viewThemeUtils = LocalViewThemeUtils.current + val colorScheme = viewThemeUtils.getColorScheme(LocalContext.current) + + val text = when (date) { + LocalDate.now() -> "Today" + LocalDate.now().minusDays(1) -> "Yesterday" + else -> date.format(DateTimeFormatter.ofPattern("MMM dd, yyyy")) + } + + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + modifier = Modifier + .background( + colorScheme.secondaryContainer, + RoundedCornerShape(12.dp) + ) + .padding(horizontal = 12.dp, vertical = 6.dp), + fontSize = 12.sp, + color = colorScheme.onSecondaryContainer + ) + } +} + +@Composable +fun UnreadMessagesMarker() { + val viewThemeUtils = LocalViewThemeUtils.current + val colorScheme = viewThemeUtils.getColorScheme(LocalContext.current) + + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Unread messages", + modifier = Modifier + .background( + colorScheme.secondaryContainer, + RoundedCornerShape(12.dp) + ) + .padding(horizontal = 12.dp, vertical = 6.dp), + fontSize = 12.sp, + color = colorScheme.onSecondaryContainer + ) + } +} + +fun formatTime(timestampMillis: Long): String { + val instant = Instant.ofEpochMilli(timestampMillis) + val dateTime = instant.atZone(ZoneId.systemDefault()).toLocalDate() + return formatTime(dateTime) +} + +fun formatTime(localDate: LocalDate): String { + val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy") + val text = when (localDate) { + LocalDate.now() -> "Today" + LocalDate.now().minusDays(1) -> "Yesterday" + else -> localDate.format(formatter) + } + return text +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt new file mode 100644 index 00000000000..c4ff321ed11 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt @@ -0,0 +1,54 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageTypeContent + +private const val AUTHOR_TEXT_SIZE = 12 + +@Composable +fun DeckMessage(typeContent: MessageTypeContent.Deck, message: ChatMessageUi, conversationThreadId: Long? = null) { + MessageScaffold( + uiMessage = message, + conversationThreadId = conversationThreadId, + content = { + Column { + if (typeContent.cardName.isNotEmpty()) { + val cardDescription = String.format( + LocalResources.current.getString(R.string.deck_card_description), + typeContent.stackName, + typeContent.boardName + ) + Row(modifier = Modifier.padding(start = 8.dp)) { + Icon(painterResource(R.drawable.deck), "") + Text( + text = typeContent.cardName, + fontSize = AUTHOR_TEXT_SIZE.sp, + fontWeight = FontWeight.Bold + ) + } + Text(cardDescription, fontSize = AUTHOR_TEXT_SIZE.sp) + } + } + } + ) +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/GeolocationMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/GeolocationMessage.kt new file mode 100644 index 00000000000..aa4837a2979 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/GeolocationMessage.kt @@ -0,0 +1,93 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageTypeContent +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker + +private const val MAP_ZOOM = 15.0 + +@Composable +fun GeolocationMessage( + typeContent: MessageTypeContent.Geolocation, + message: ChatMessageUi +) { + typeContent.lat + typeContent.lon + + // MessageScaffold( + // uiMessage = message, + // conversationThreadId = conversationThreadId, + // playAnimation = state.value, + // content = { + // Column { + // if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { + // for (key in message.messageParameters!!.keys) { + // val individualHashMap: Map = message.messageParameters!![key]!! + // if (individualHashMap["type"] == "geo-location") { + // val lat = individualHashMap["latitude"] + // val lng = individualHashMap["longitude"] + // + // if (lat != null && lng != null) { + // val latitude = lat.toDouble() + // val longitude = lng.toDouble() + // OpenStreetMap(latitude, longitude) + // } + // } + // } + // } + // } + // } + // ) +} + +@Composable +private fun OpenStreetMap(latitude: Double, longitude: Double) { + AndroidView( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(), + factory = { context -> + Configuration.getInstance().userAgentValue = context.packageName + MapView(context).apply { + setTileSource(TileSourceFactory.MAPNIK) + setMultiTouchControls(true) + + val geoPoint = GeoPoint(latitude, longitude) + controller.setCenter(geoPoint) + controller.setZoom(MAP_ZOOM) + + val marker = Marker(this) + marker.position = geoPoint + marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + marker.title = "Location" + overlays.add(marker) + + invalidate() + } + }, + update = { mapView -> + val geoPoint = GeoPoint(latitude, longitude) + mapView.controller.setCenter(geoPoint) + + val marker = mapView.overlays.find { it is Marker } as? Marker + marker?.position = geoPoint + mapView.invalidate() + } + ) +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt new file mode 100644 index 00000000000..85067f83384 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt @@ -0,0 +1,106 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageTypeContent +import com.nextcloud.talk.contacts.load +import com.nextcloud.talk.utils.DateUtils + +@Composable +fun ImageMessage( + typeContent: MessageTypeContent.Image, + message: ChatMessageUi, + conversationThreadId: Long? = null +) { + val hasCaption = (message.message != "{file}") + + val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) + MessageScaffold( + uiMessage = message, + conversationThreadId = conversationThreadId, + includePadding = false, + content = { + Column { + val loadedImage = load( + imageUri = typeContent.imageUrl, + context = LocalContext.current, + errorPlaceholderImage = typeContent.drawableResourceId + ) + + AsyncImage( + model = loadedImage, + contentDescription = stringResource(R.string.nc_sent_an_image), + modifier = Modifier + .fillMaxWidth(), + contentScale = ContentScale.FillWidth + ) + + if (hasCaption) { + Text( + message.text, + fontSize = 12.sp, + modifier = Modifier + .widthIn(20.dp, 140.dp) + .padding(8.dp) + ) + } + } + } + ) + + if (!hasCaption) { + Row(modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { + if (!message.incoming) { + Spacer(Modifier.weight(1f)) + } else { + Spacer(Modifier.size(width = 56.dp, 0.dp)) // To account for avatar size + } + Text(message.text, fontSize = 12.sp) + Text( + timeString, + fontSize = 12.sp, + textAlign = TextAlign.End, + modifier = Modifier + .align(Alignment.CenterVertically) + .padding() + .padding(start = 4.dp) + ) + // if (message.readStatus == ReadStatus.NONE) { + // val read = painterResource(R.drawable.ic_check_all) + // Icon( + // read, + // "", + // modifier = Modifier + // .padding(start = 4.dp) + // .size(16.dp) + // .align(Alignment.CenterVertically) + // ) + // } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt new file mode 100644 index 00000000000..3adec68ffc9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt @@ -0,0 +1,74 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.runtime.Composable +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageTypeContent + +private const val REGULAR_TEXT_SIZE = 16 +private const val AUTHOR_TEXT_SIZE = 12 +private const val TIME_TEXT_SIZE = 12 + +@Composable +fun LinkMessage( + typeContent: MessageTypeContent.LinkPreview, + message: ChatMessageUi, + conversationThreadId: Long? = null +) { + // val color = colorResource(R.color.high_emphasis_text) + // adapter.viewModel.chatViewModel.getOpenGraph( + // adapter.currentUser.getCredentials(), + // adapter.currentUser.baseUrl!!, + // message.extractedUrlToPreview!! + // ) + // CommonMessageBody(message, playAnimation = state.value) { + // EnrichedText(message) + // Row( + // modifier = Modifier + // .drawWithCache { + // onDrawWithContent { + // drawLine( + // color = color, + // start = Offset.Zero, + // end = Offset(0f, this.size.height), + // strokeWidth = 4f, + // cap = StrokeCap.Round + // ) + // + // drawContent() + // } + // } + // .padding(8.dp) + // .padding(4.dp) + // ) { + // Column { + // val graphObject = adapter.viewModel.chatViewModel.getOpenGraph.asFlow().collectAsState( + // Reference( + // // Dummy class + // ) + // ).value.openGraphObject + // graphObject?.let { + // Text(it.name, fontSize = REGULAR_TEXT_SIZE.sp, fontWeight = FontWeight.Bold) + // it.description?.let { Text(it, fontSize = AUTHOR_TEXT_SIZE.sp) } + // it.link?.let { Text(it, fontSize = TIME_TEXT_SIZE.sp) } + // it.thumb?.let { + // val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image + // val loadedImage = load(it, LocalContext.current, errorPlaceholderImage) + // AsyncImage( + // model = loadedImage, + // contentDescription = stringResource(R.string.nc_sent_an_image), + // modifier = Modifier + // .height(120.dp) + // ) + // } + // } + // } + // } + // } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/PollMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/PollMessage.kt new file mode 100644 index 00000000000..5375b7576f6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/PollMessage.kt @@ -0,0 +1,67 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageTypeContent + +private const val AUTHOR_TEXT_SIZE = 12 + +@Composable +fun PollMessage( + typeContent: MessageTypeContent.Poll, + message: ChatMessageUi, + conversationThreadId: Long? = null +) { + MessageScaffold( + uiMessage = message, + conversationThreadId = conversationThreadId, + content = { + Column { + Row(modifier = Modifier.padding(start = 8.dp)) { + Icon(painterResource(R.drawable.ic_baseline_bar_chart_24), "") + Text( + typeContent.pollName, + fontSize = AUTHOR_TEXT_SIZE.sp, + fontWeight = FontWeight.Bold + ) + } + + TextButtonNoStyling(stringResource(R.string.message_poll_tap_to_open)) { + // NOTE: read only for now + } + } + } + ) +} + +@Composable +private fun TextButtonNoStyling(text: String, onClick: () -> Unit) { + TextButton(onClick = onClick) { + Text( + text, + fontSize = AUTHOR_TEXT_SIZE.sp, + color = Color.White + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/Shimmer.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/Shimmer.kt new file mode 100644 index 00000000000..a629c0778dd --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/Shimmer.kt @@ -0,0 +1,118 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import android.widget.LinearLayout +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.elyeproj.loaderviewlibrary.LoaderImageView +import com.elyeproj.loaderviewlibrary.LoaderTextView +import com.nextcloud.talk.R + +private const val INT_8 = 8 +private const val INT_128 = 128 + +@Composable +fun ShimmerGroup() { + Shimmer() + Shimmer(true) + Shimmer() + Shimmer(true) + Shimmer(true) + Shimmer() + Shimmer(true) +} + +@Composable +private fun Shimmer(outgoing: Boolean = false) { + val outgoingColor = colorScheme.primary.toArgb() + + Row(modifier = Modifier.padding(top = 16.dp)) { + if (!outgoing) { + ShimmerImage(this) + } + + val v1 by remember { mutableIntStateOf((INT_8..INT_128).random()) } + val v2 by remember { mutableIntStateOf((INT_8..INT_128).random()) } + val v3 by remember { mutableIntStateOf((INT_8..INT_128).random()) } + + Column { + ShimmerText(this, v1, outgoing, outgoingColor) + ShimmerText(this, v2, outgoing, outgoingColor) + ShimmerText(this, v3, outgoing, outgoingColor) + } + } +} + +@Composable +private fun ShimmerImage(rowScope: RowScope) { + rowScope.apply { + AndroidView( + factory = { ctx -> + LoaderImageView(ctx).apply { + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + val color = resources.getColor(R.color.nc_shimmer_default_color, null) + setBackgroundColor(color) + } + }, + modifier = Modifier + .clip(CircleShape) + .size(40.dp) + .align(Alignment.Top) + ) + } +} + +@Composable +private fun ShimmerText(columnScope: ColumnScope, margin: Int, outgoing: Boolean = false, outgoingColor: Int) { + columnScope.apply { + AndroidView( + factory = { ctx -> + LoaderTextView(ctx).apply { + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + val color = if (outgoing) { + outgoingColor + } else { + resources.getColor(R.color.nc_shimmer_default_color, null) + } + + setBackgroundColor(color) + } + }, + modifier = Modifier.padding( + top = 6.dp, + end = if (!outgoing) margin.dp else 8.dp, + start = if (outgoing) margin.dp else 8.dp + ) + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt new file mode 100644 index 00000000000..1f473081983 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt @@ -0,0 +1,60 @@ +package com.nextcloud.talk.ui.chat + +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import com.nextcloud.talk.chat.ui.model.ChatMessageUi + +private const val AUTHOR_TEXT_SIZE = 12 +private const val TIME_TEXT_SIZE = 12 +private const val FLOAT_06 = 0.6f + +@Composable +fun SystemMessage( + message: ChatMessageUi +) { + // val similarMessages = NextcloudTalkApplication.sharedApplication!!.resources.getQuantityString( + // R.plurals.see_similar_system_messages, + // message.expandableChildrenAmount, + // message.expandableChildrenAmount + // ) + // Column(horizontalAlignment = Alignment.CenterHorizontally) { + // val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) + // Row(horizontalArrangement = Arrangement.Absolute.Center, verticalAlignment = Alignment.CenterVertically) { + // Spacer(modifier = Modifier.weight(1f)) + // Text( + // message.text, + // fontSize = AUTHOR_TEXT_SIZE.sp, + // modifier = Modifier + // .padding(8.dp) + // .fillMaxWidth(FLOAT_06) + // ) + // Text( + // timeString, + // fontSize = TIME_TEXT_SIZE.sp, + // textAlign = TextAlign.End, + // modifier = Modifier.align(Alignment.CenterVertically) + // ) + // Spacer(modifier = Modifier.weight(1f)) + // } + // + // if (message.expandableChildrenAmount > 0) { + // TextButtonNoStyling(similarMessages) { + // // NOTE: Read only for now + // } + // } + // } +} + +@Composable +private fun TextButtonNoStyling(text: String, onClick: () -> Unit) { + TextButton(onClick = onClick) { + Text( + text, + fontSize = AUTHOR_TEXT_SIZE.sp, + color = Color.White + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt new file mode 100644 index 00000000000..f6d44078a24 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.runtime.Composable +import com.nextcloud.talk.chat.ui.model.ChatMessageUi + +@Composable +fun TextMessage( + uiMessage: ChatMessageUi, + showAvatar: Boolean, + conversationThreadId: Long? = null +) { + MessageScaffold( + uiMessage = uiMessage, + conversationThreadId = conversationThreadId, + showAvatar = showAvatar, + content = { + EnrichedText( + uiMessage + ) + } + ) +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt new file mode 100644 index 00000000000..ec5cc9557b9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt @@ -0,0 +1,70 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageTypeContent +import com.nextcloud.talk.ui.WaveformSeekBar +import kotlin.random.Random + +private const val DEFAULT_WAVE_SIZE = 50 + +@Composable +fun VoiceMessage( + typeContent: MessageTypeContent.Voice, + message: ChatMessageUi, + conversationThreadId: Long? = null +) { + MessageScaffold( + uiMessage = message, + conversationThreadId = conversationThreadId, + content = { + val inversePrimary = colorScheme.inversePrimary.toArgb() + val onPrimaryContainer = colorScheme.onPrimaryContainer.toArgb() + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.PlayArrow, + contentDescription = "play", + modifier = Modifier.size(24.dp) + ) + + AndroidView( + factory = { ctx -> + WaveformSeekBar(ctx).apply { + setWaveData(FloatArray(DEFAULT_WAVE_SIZE) { Random.nextFloat() }) + setColors( + inversePrimary, + onPrimaryContainer + ) + } + }, + modifier = Modifier + .width(180.dp) + .height(80.dp) + ) + } + } + ) +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt index 8df5f2ad15d..25c35dd4185 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt @@ -77,7 +77,7 @@ import java.time.temporal.TemporalAdjusters.nextOrSame import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) -class DateTimeCompose(val bundle: Bundle) { +class DateTimeCompose(val bundle: Bundle, val chatViewModel: ChatViewModel) { private var timeState = mutableStateOf(LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.MIN)) init { @@ -89,9 +89,6 @@ class DateTimeCompose(val bundle: Bundle) { chatViewModel.getReminder(user, roomToken, messageId, apiVersion) } - @Inject - lateinit var chatViewModel: ChatViewModel - @Inject lateinit var currentUserProvider: CurrentUserProviderOld diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/CompositionLocals.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/CompositionLocals.kt new file mode 100644 index 00000000000..101e5813521 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/CompositionLocals.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.theme + +import androidx.compose.runtime.staticCompositionLocalOf +import com.nextcloud.talk.utils.message.MessageUtils + +val LocalViewThemeUtils = staticCompositionLocalOf { + error("ViewThemeUtils not provided") +} + +val LocalMessageUtils = staticCompositionLocalOf { + error("MessageUtils not provided") +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt index efeb2135ab5..965d2387a05 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt @@ -8,7 +8,9 @@ package com.nextcloud.talk.utils.database.user import com.nextcloud.talk.data.user.model.User +import kotlinx.coroutines.flow.Flow interface CurrentUserProvider { + val currentUserFlow: Flow suspend fun getCurrentUser(timeout: Long = 5000L): Result } diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderImpl.kt index d7d7c92b064..25bfdbe0f6c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProviderImpl.kt @@ -37,7 +37,7 @@ class CurrentUserProviderImpl @Inject constructor(private val userManager: UserM ) // only emit non-null users - val currentUserFlow: Flow = currentUser.filterNotNull() + override val currentUserFlow: Flow = currentUser.filterNotNull() // function for safe one-shot access override suspend fun getCurrentUser(timeout: Long): Result { diff --git a/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt index 6beeafecab9..6f45c375b1c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt @@ -18,6 +18,7 @@ import android.view.View import androidx.core.net.toUri import com.nextcloud.talk.R import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.chat.ui.model.ChatMessageUi import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.DisplayUtils import io.noties.markwon.AbstractMarkwonPlugin @@ -49,6 +50,7 @@ class MessageUtils(val context: Context) { ) } + @Deprecated("delete with chatkit") fun enrichChatMessageText( context: Context, message: ChatMessage, @@ -64,6 +66,19 @@ class MessageUtils(val context: Context) { enrichChatMessageText(context, newMessage, incoming, viewThemeUtils) } + fun enrichChatMessageUiText( + context: Context, + message: ChatMessageUi, + incoming: Boolean, + viewThemeUtils: ViewThemeUtils + ): Spanned? = + if (!message.renderMarkdown) { + SpannableString(message.message) + } else { + val newMessage = message.message!!.replace("\n", " \n", false) + enrichChatMessageText(context, newMessage, incoming, viewThemeUtils) + } + fun enrichChatMessageText( context: Context, message: String, diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt index db7095f025b..3e8b17e7f1b 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt @@ -47,6 +47,7 @@ import com.nextcloud.talk.ui.theme.MaterialSchemesProviderImpl import com.nextcloud.talk.ui.theme.TalkSpecificViewThemeUtils import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.database.user.CurrentUserProviderImpl import com.nextcloud.talk.utils.database.user.CurrentUserProviderOldImpl import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.message.MessageUtils @@ -171,16 +172,22 @@ class ComposePreviewUtils private constructor(context: Context) { val audioFocusRequestManager: AudioFocusRequestManager get() = AudioFocusRequestManager(mContext) + val currentUserProvider: CurrentUserProviderImpl + get() = CurrentUserProviderImpl(userManager) + val chatViewModel: ChatViewModel get() = ChatViewModel( - appPreferences, - chatNetworkDataSource, - chatRepository, - threadsRepository, - conversationRepository, - reactionsRepository, - mediaRecorderManager, - audioFocusRequestManager + appPreferences = appPreferences, + chatNetworkDataSource = chatNetworkDataSource, + chatRepository = chatRepository, + threadsRepository = threadsRepository, + conversationRepository = conversationRepository, + reactionsRepository = reactionsRepository, + mediaRecorderManager = mediaRecorderManager, + audioFocusRequestManager = audioFocusRequestManager, + currentUserProvider = currentUserProvider, + chatRoomToken = "", + conversationThreadId = null ) val contactsRepository: ContactsRepository diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt index 1b85bdcd4b4..560d36f0c45 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt @@ -23,7 +23,16 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf class DummyChatMessagesDaoImpl : ChatMessagesDao { - override fun getMessagesForConversation(internalConversationId: String): Flow> = flowOf() + override fun getMessagesNewerThan( + internalConversationId: String, + threadId: Long?, + oldestMessageId: Long + ): Flow> = flowOf() + + override fun getMessagesForConversation( + internalConversationId: String, + threadId: Long? + ): Flow> = flowOf() override fun getTempMessagesForConversation(internalConversationId: String): Flow> = flowOf() @@ -58,6 +67,18 @@ class DummyChatMessagesDaoImpl : ChatMessagesDao { override suspend fun getChatMessageEntity(internalConversationId: String, messageId: Long): ChatMessageEntity? = null + override fun observeMessage( + internalConversationId: String, + messageId: Long + ): Flow = flowOf() + + override suspend fun getChatMessageOnce(internalConversationId: String, messageId: Long): ChatMessageEntity? = null + + override fun getChatMessageForConversationNullable( + internalConversationId: String, + messageId: Long + ): Flow = flowOf() + override fun deleteChatMessages(internalIds: List) { /* */ } @@ -259,4 +280,5 @@ class DummyChatBlocksDaoImpl : ChatBlocksDao { override fun deleteChatBlocksOlderThan(internalConversationId: String, messageId: Long) { /* */ } + override fun getLatestChatBlock(internalConversationId: String, threadId: Long?): Flow = flowOf() } diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketConnectionHelper.java b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketConnectionHelper.java index 57452c0943a..5a05fc81ff8 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketConnectionHelper.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketConnectionHelper.java @@ -26,6 +26,7 @@ import com.nextcloud.talk.utils.ApiUtils; import java.util.HashMap; +import java.util.List; import java.util.Map; import javax.inject.Inject; @@ -116,6 +117,10 @@ HelloOverallWebSocketMessage getAssembledHelloModel(User user, String ticket) { } authWebSocketMessage.setAuthParametersWebSocketMessage(authParametersWebSocketMessage); helloWebSocketMessage.setAuthWebSocketMessage(authWebSocketMessage); + + List features = List.of("chat-relay"); + helloWebSocketMessage.setFeatures(features); + helloOverallWebSocketMessage.setHelloWebSocketMessage(helloWebSocketMessage); return helloOverallWebSocketMessage; } @@ -126,6 +131,8 @@ HelloOverallWebSocketMessage getAssembledHelloModelForResume(String resumeId) { HelloWebSocketMessage helloWebSocketMessage = new HelloWebSocketMessage(); helloWebSocketMessage.setVersion("1.0"); helloWebSocketMessage.setResumeid(resumeId); + List features = List.of("chat-relay"); + helloWebSocketMessage.setFeatures(features); helloOverallWebSocketMessage.setHelloWebSocketMessage(helloWebSocketMessage); return helloOverallWebSocketMessage; } diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt index 81b784726e3..5d6e73e055e 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt @@ -66,6 +66,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU var sessionId: String? = null private set private var hasMCU = false + private var supportsChatRelay = false var isConnected: Boolean private set private val webSocketConnectionHelper: WebSocketConnectionHelper @@ -183,7 +184,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU ncSignalingMessage.from = callWebSocketMessage.senderWebSocketMessage!!.sessionId } - signalingMessageReceiver.process(callWebSocketMessage) + signalingMessageReceiver.processChatMessage(callWebSocketMessage) } } @@ -196,17 +197,17 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU when (target) { Globals.TARGET_ROOM -> { if ("message" == eventOverallWebSocketMessage.eventMap!!["type"]) { - processRoomMessageMessage(eventOverallWebSocketMessage) + processRoomMessageMessage(eventOverallWebSocketMessage, text) } else if ("join" == eventOverallWebSocketMessage.eventMap!!["type"]) { processRoomJoinMessage(eventOverallWebSocketMessage) } else if ("leave" == eventOverallWebSocketMessage.eventMap!!["type"]) { processRoomLeaveMessage(eventOverallWebSocketMessage) } - signalingMessageReceiver.process(eventOverallWebSocketMessage.eventMap) + signalingMessageReceiver.processChatMessage(eventOverallWebSocketMessage.eventMap) } Globals.TARGET_PARTICIPANTS -> - signalingMessageReceiver.process(eventOverallWebSocketMessage.eventMap) + signalingMessageReceiver.processChatMessage(eventOverallWebSocketMessage.eventMap) else -> Log.i(TAG, "Received unknown/ignored event target: $target") @@ -217,7 +218,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU } } - private fun processRoomMessageMessage(eventOverallWebSocketMessage: EventOverallWebSocketMessage) { + private fun processRoomMessageMessage(eventOverallWebSocketMessage: EventOverallWebSocketMessage, text: String) { val messageHashMap = eventOverallWebSocketMessage.eventMap?.get("message") as Map<*, *>? if (messageHashMap != null && messageHashMap.containsKey("data")) { @@ -231,6 +232,10 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU refreshChatHashMap[BundleKeys.KEY_INTERNAL_USER_ID] = (conversationUser.id!!).toString() eventBus!!.post(WebSocketCommunicationEvent("refreshChat", refreshChatHashMap)) } + + if (chatMap != null && chatMap.containsKey("comment")) { + signalingMessageReceiver.processChatMessage(text) + } } else if (dataHashMap != null && dataHashMap.containsKey("recording")) { val recordingMap = dataHashMap["recording"] as Map<*, *>? if (recordingMap != null && recordingMap.containsKey("status")) { @@ -318,6 +323,15 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU resumeId = helloResponseWebSocketMessage1.resumeId sessionId = helloResponseWebSocketMessage1.sessionId hasMCU = helloResponseWebSocketMessage1.serverHasMCUSupport() + + val features = + helloResponseWebSocketMessage1.serverHelloResponseFeaturesWebSocketMessage?.features ?: emptyList() + supportsChatRelay = features.contains("chat-relay") + if (supportsChatRelay) { + Log.d(TAG, "chat-relay is supported") + } else { + Log.d(TAG, "chat-relay is NOT supported") + } } for (i in messagesQueue.indices) { webSocket.send(messagesQueue[i]) @@ -361,6 +375,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU } fun hasMCU(): Boolean = hasMCU + fun supportsChatRelay(): Boolean = supportsChatRelay @Suppress("Detekt.ComplexMethod") fun joinRoomWithRoomTokenAndSession( @@ -468,11 +483,11 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU * stays connected, but it may change whenever it is connected again. */ private class ExternalSignalingMessageReceiver : SignalingMessageReceiver() { - fun process(eventMap: Map?) { + fun processChatMessage(eventMap: Map?) { processEvent(eventMap) } - fun process(message: CallWebSocketMessage?) { + fun processChatMessage(message: CallWebSocketMessage?) { if (message?.ncSignalingMessage?.type == "startedTyping" || message?.ncSignalingMessage?.type == "stoppedTyping" ) { @@ -481,6 +496,11 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU processSignalingMessage(message?.ncSignalingMessage) } } + + fun processChatMessage(jsonString: String) { + processChatMessageWebSocketMessage(jsonString) + Log.d(TAG, "processing Received chat message") + } } inner class ExternalSignalingMessageSender : SignalingMessageSender { diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index 52e223c505d..421c3badf23 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -13,7 +13,6 @@ android:id="@+id/chat_container" android:layout_width="match_parent" android:layout_height="match_parent" - android:animateLayoutChanges="true" android:background="@color/bg_default" android:orientation="vertical" tools:ignore="Overdraw"> @@ -29,7 +28,6 @@ android:layout_height="?attr/actionBarSize" android:background="@color/appbar" android:theme="?attr/actionBarPopupTheme" - app:layout_scrollFlags="scroll|enterAlways" app:navigationIconTint="@color/fontAppbar" app:popupTheme="@style/appActionBarPopupMenu"> @@ -140,42 +138,55 @@ - + + + + + + - app:dateHeaderTextSize="13sp" - app:incomingBubblePaddingBottom="@dimen/message_bubble_corners_vertical_padding" - app:incomingBubblePaddingLeft="@dimen/message_bubble_corners_horizontal_padding" - app:incomingBubblePaddingRight="@dimen/message_bubble_corners_horizontal_padding" - app:incomingBubblePaddingTop="@dimen/message_bubble_corners_vertical_padding" - app:incomingDefaultBubbleColor="@color/bg_message_list_incoming_bubble" - app:incomingDefaultBubblePressedColor="@color/bg_message_list_incoming_bubble" - app:incomingDefaultBubbleSelectedColor="@color/transparent" - app:incomingImageTimeTextSize="12sp" - app:incomingTextColor="@color/nc_incoming_text_default" - app:incomingTextLinkColor="@color/nc_incoming_text_default" - app:incomingTextSize="@dimen/chat_text_size" - app:incomingTimeTextColor="@color/no_emphasis_text" - app:incomingTimeTextSize="12sp" - app:outcomingBubblePaddingBottom="@dimen/message_bubble_corners_vertical_padding" - app:outcomingBubblePaddingLeft="@dimen/message_bubble_corners_horizontal_padding" - app:outcomingBubblePaddingRight="@dimen/message_bubble_corners_horizontal_padding" - app:outcomingBubblePaddingTop="@dimen/message_bubble_corners_vertical_padding" - app:outcomingDefaultBubbleColor="@color/colorPrimary" - app:outcomingDefaultBubblePressedColor="@color/colorPrimary" - app:outcomingDefaultBubbleSelectedColor="@color/transparent" - app:outcomingImageTimeTextSize="12sp" - app:outcomingTextColor="@color/high_emphasis_text" - app:outcomingTextLinkColor="@color/high_emphasis_text" - app:outcomingTextSize="@dimen/chat_text_size" - app:outcomingTimeTextSize="12sp" - app:textAutoLink="all" - tools:visibility="visible" /> + +