From f9d63653722e233e918f79a00534bd5d60ba1148 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 10 Dec 2025 17:19:17 +0100 Subject: [PATCH] new chat architecture incl chat relay Signed-off-by: Marcel Hibbe --- .../data/database/dao/ChatMessagesDaoTest.kt | 10 +- .../database/migrations/MigrationsTest.kt | 110 +- .../nextcloud/talk/activities/BaseActivity.kt | 4 + .../nextcloud/talk/activities/CallActivity.kt | 11 +- .../talk/activities/ParticipantHandler.kt | 2 +- .../talk/adapters/items/ConversationItem.kt | 4 +- .../messages/IncomingTextMessageViewHolder.kt | 7 +- .../OutcomingTextMessageViewHolder.kt | 7 +- .../messages/PreviewMessageViewHolder.kt | 79 +- .../talk/adapters/messages/ThreadButton.kt | 2 +- .../java/com/nextcloud/talk/api/NcApi.java | 12 - .../com/nextcloud/talk/api/NcApiCoroutines.kt | 30 + .../com/nextcloud/talk/chat/ChatActivity.kt | 1192 ++++++++++------- .../talk/chat/MessageInputFragment.kt | 2 + .../talk/chat/UnreadMessagesPopup.kt | 54 + .../talk/chat/data/ChatMessageRepository.kt | 39 +- .../talk/chat/data/io/MediaPlayerManager.kt | 27 +- .../talk/chat/data/model/ChatMessage.kt | 209 +-- .../chat/data/model/DeckCardParameters.kt | 20 + .../talk/chat/data/model/FileParameters.kt | 32 + .../chat/data/model/GeoLocationParameters.kt | 18 + .../talk/chat/data/model/PollParameters.kt | 15 + .../chat/data/model/RichObjectParameters.kt | 22 + .../data/network/ChatNetworkDataSource.kt | 7 +- .../network/OfflineFirstChatRepository.kt | 992 ++++++++------ .../chat/data/network/RetrofitChatNetwork.kt | 6 +- .../talk/chat/domain/ChatPullResult.kt | 18 + .../talk/chat/ui/model/ChatMessageUi.kt | 280 ++++ .../talk/chat/viewmodels/ChatViewModel.kt | 884 +++++++++--- .../talk/contextchat/ContextChatView.kt | 155 +-- .../talk/contextchat/ContextChatViewModel.kt | 4 - .../ConversationsListActivity.kt | 116 +- .../data/OfflineConversationsRepository.kt | 7 + .../OfflineFirstConversationsRepository.kt | 25 +- .../talk/dagger/modules/RepositoryModule.kt | 4 +- .../talk/dagger/modules/ViewModelModule.kt | 25 +- .../talk/data/database/dao/ChatBlocksDao.kt | 12 + .../talk/data/database/dao/ChatMessagesDao.kt | 77 +- .../database/mappers/ChatMessageMapUtils.kt | 7 +- .../database/mappers/ConversationMapUtils.kt | 2 +- .../talk/data/source/local/TalkDatabase.kt | 2 +- .../json/websocket/HelloWebSocketMessage.kt | 4 +- .../reactions/ReactionsRepository.kt | 9 +- .../reactions/ReactionsRepositoryImpl.kt | 153 +-- .../activities/SharedItemsActivity.kt | 37 +- .../adapters/SharedItemsViewHolder.kt | 10 +- .../signaling/ConversationMessageNotifier.kt | 8 + ...eiver.java => SignalingMessageReceiver.kt} | 523 ++++---- .../ThreadsOverviewActivity.kt | 4 +- .../nextcloud/talk/ui/ComposeChatAdapter.kt | 1180 ---------------- .../talk/ui/chat/ChatMessageScaffold.kt | 998 ++++++++++++++ .../nextcloud/talk/ui/chat/ChatMessageView.kt | 337 +++++ .../com/nextcloud/talk/ui/chat/ChatView.kt | 437 ++++++ .../com/nextcloud/talk/ui/chat/DeckMessage.kt | 61 + .../talk/ui/chat/GeolocationMessage.kt | 282 ++++ .../com/nextcloud/talk/ui/chat/LinkMessage.kt | 127 ++ .../nextcloud/talk/ui/chat/MediaMessage.kt | 111 ++ .../talk/ui/chat/MentionEnrichedText.kt | 391 ++++++ .../com/nextcloud/talk/ui/chat/PollMessage.kt | 74 + .../com/nextcloud/talk/ui/chat/Shimmer.kt | 118 ++ .../nextcloud/talk/ui/chat/SystemMessage.kt | 43 + .../com/nextcloud/talk/ui/chat/TextMessage.kt | 29 + .../nextcloud/talk/ui/chat/VoiceMessage.kt | 146 ++ .../talk/ui/dialog/DateTimeCompose.kt | 5 +- .../talk/ui/dialog/MessageActionsDialog.kt | 100 +- .../talk/ui/dialog/ShowReactionsDialog.kt | 126 +- .../talk/ui/theme/CompositionLocals.kt | 25 + .../nextcloud/talk/utils/FileViewerUtils.kt | 53 +- .../database/user/CurrentUserProvider.kt | 2 + .../database/user/CurrentUserProviderImpl.kt | 2 +- .../talk/utils/message/MessageUtils.kt | 15 + .../talk/utils/preview/ComposePreviewUtils.kt | 25 +- .../utils/preview/ComposePreviewUtilsDaos.kt | 29 +- .../webrtc/WebSocketConnectionHelper.java | 7 + .../talk/webrtc/WebSocketInstance.kt | 34 +- app/src/main/res/layout/activity_chat.xml | 97 +- .../talk/json/ConversationConversionTest.kt | 4 +- 77 files changed, 6834 insertions(+), 3303 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/UnreadMessagesPopup.kt create mode 100644 app/src/main/java/com/nextcloud/talk/chat/data/model/DeckCardParameters.kt create mode 100644 app/src/main/java/com/nextcloud/talk/chat/data/model/FileParameters.kt create mode 100644 app/src/main/java/com/nextcloud/talk/chat/data/model/GeoLocationParameters.kt create mode 100644 app/src/main/java/com/nextcloud/talk/chat/data/model/PollParameters.kt create mode 100644 app/src/main/java/com/nextcloud/talk/chat/data/model/RichObjectParameters.kt create mode 100644 app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt create mode 100644 app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt rename app/src/main/java/com/nextcloud/talk/signaling/{SignalingMessageReceiver.java => SignalingMessageReceiver.kt} (64%) delete mode 100644 app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/GeolocationMessage.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/MentionEnrichedText.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/PollMessage.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/Shimmer.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/theme/CompositionLocals.kt 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..842ec820a95 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 @@ -108,7 +108,7 @@ class ChatMessagesDaoTest { assertEquals(conversation1, conversation1GotByToken) // Lets insert some messages to the conversations - chatMessagesDao.upsertChatMessages( + chatMessagesDao.upsertChatMessagesAndDeleteTemp( listOf( createChatMessageEntity(conversation1.internalId, "hello"), createChatMessageEntity(conversation1.internalId, "here"), @@ -117,22 +117,22 @@ class ChatMessagesDaoTest { createChatMessageEntity(conversation1.internalId, "messages") ) ) - chatMessagesDao.upsertChatMessages( + chatMessagesDao.upsertChatMessagesAndDeleteTemp( listOf( createChatMessageEntity(conversation2.internalId, "first message in conversation 2") ) ) - 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..1afa5a53fb2 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,8 +7,8 @@ package com.nextcloud.talk.data.database.migrations -import androidx.room.Room 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 @@ -20,10 +20,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 +31,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 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 ffaf644507f..7ce1a46df1f 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 f6f015dc644..a65566be5ea 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 @@ -11,6 +11,7 @@ package com.nextcloud.talk.adapters.messages import android.annotation.SuppressLint import android.content.Context +import android.text.SpannableStringBuilder import android.util.Log import android.util.TypedValue import android.view.GestureDetector @@ -154,10 +155,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 46e352b6d6d..261020c75fd 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 @@ -11,6 +11,7 @@ package com.nextcloud.talk.adapters.messages import android.annotation.SuppressLint import android.content.Context +import android.text.SpannableStringBuilder import android.util.Log import android.util.TypedValue import android.view.GestureDetector @@ -167,10 +168,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 f0ccd02c0f8..9cd893b2130 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 @@ -47,8 +47,6 @@ import com.nextcloud.talk.utils.FileViewerUtils import com.nextcloud.talk.utils.FileViewerUtils.ProgressUi import com.nextcloud.talk.utils.message.MessageUtils import com.stfalcon.chatkit.messages.MessageHolders.IncomingImageMessageViewHolder -import coil.load -import com.nextcloud.talk.utils.ApiUtils import io.reactivex.Single import io.reactivex.SingleObserver import io.reactivex.disposables.Disposable @@ -102,22 +100,6 @@ abstract class PreviewMessageViewHolder(itemView: View?, payload: Any?) : super.onBind(message) image.minimumHeight = DisplayUtils.convertDpToPixel(MIN_IMAGE_HEIGHT, context!!).toInt() - // Reset state for view recycling - image.adjustViewBounds = false - messageText.visibility = View.VISIBLE - - // Check if image is GIF and load animated image - if (message.imageUrl != null && message.shouldAutoplayGif()) { - image.adjustViewBounds = true - image.load(message.imageUrl) { - size(coil.size.Size.ORIGINAL) - addHeader( - "Authorization", - ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!! - ) - } - } - if (message.lastEditTimestamp != 0L && !message.isDeleted) { time.text = dateUtils.getLocalTimeStringFromTimestamp(message.lastEditTimestamp!!) messageEditIndicator.visibility = View.VISIBLE @@ -128,42 +110,39 @@ abstract class PreviewMessageViewHolder(itemView: View?, payload: Any?) : viewThemeUtils!!.platform.colorCircularProgressBar(progressBar!!, ColorRole.PRIMARY) 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 - - // hide filename display for GIF images - if (message.shouldAutoplayGif()) { - messageText.visibility = View.INVISIBLE - } - - 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/adapters/messages/ThreadButton.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/ThreadButton.kt index 630eff70de1..a4d295be555 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/ThreadButton.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/ThreadButton.kt @@ -44,7 +44,7 @@ fun ThreadButtonComposable(replyAmount: Int = 0, onButtonClick: () -> Unit = {}) modifier = Modifier .padding(0.dp) .height(24.dp), - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(8.dp), border = BorderStroke(1.dp, colorResource(R.color.nc_incoming_text_default)), contentPadding = PaddingValues(0.dp), colors = ButtonDefaults.outlinedButtonColors( 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 94cfdf4b728..9014fd085d1 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -20,6 +20,7 @@ import com.nextcloud.talk.models.json.participants.AddParticipantOverall import com.nextcloud.talk.models.json.participants.TalkBan import com.nextcloud.talk.models.json.participants.TalkBanOverall import com.nextcloud.talk.models.json.profile.ProfileOverall +import com.nextcloud.talk.models.json.reactions.ReactionsOverall import com.nextcloud.talk.models.json.status.StatusOverall import com.nextcloud.talk.models.json.testNotification.TestNotificationOverall import com.nextcloud.talk.models.json.threads.ThreadOverall @@ -28,6 +29,7 @@ import com.nextcloud.talk.models.json.upcomingEvents.UpcomingEventsOverall 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 @@ -374,4 +376,32 @@ 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 + + @POST + suspend fun sendReaction( + @Header("Authorization") authorization: String?, + @Url url: String, + @Query("reaction") reaction: String + ): GenericOverall + + @DELETE + suspend fun deleteReaction( + @Header("Authorization") authorization: String?, + @Url url: String, + @Query("reaction") reaction: String + ): GenericOverall + + @GET + suspend fun getReactions( + @Header("Authorization") authorization: String?, + @Url url: String, + @Query("reaction") reaction: String? + ): ReactionsOverall } 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 16c71517d36..6792925b64a 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -63,8 +63,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 @@ -83,8 +85,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 @@ -127,15 +132,16 @@ import com.nextcloud.talk.adapters.messages.OutcomingPreviewMessageViewHolder import com.nextcloud.talk.adapters.messages.OutcomingTextMessageViewHolder import com.nextcloud.talk.adapters.messages.OutcomingVoiceMessageViewHolder import com.nextcloud.talk.adapters.messages.PreviewMessageInterface -import com.nextcloud.talk.adapters.messages.PreviewMessageViewHolder import com.nextcloud.talk.adapters.messages.SystemMessageInterface import com.nextcloud.talk.adapters.messages.SystemMessageViewHolder import com.nextcloud.talk.adapters.messages.TalkMessagesListAdapter import com.nextcloud.talk.adapters.messages.UnreadNoticeMessageViewHolder import com.nextcloud.talk.adapters.messages.VoiceMessageInterface import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel import com.nextcloud.talk.contextchat.ContextChatView @@ -143,6 +149,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 @@ -158,23 +165,25 @@ 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 import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOverall import com.nextcloud.talk.models.json.threads.ThreadInfo import com.nextcloud.talk.polls.ui.PollCreateDialogFragment +import com.nextcloud.talk.polls.ui.PollMainDialogFragment import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity import com.nextcloud.talk.shareditems.activities.SharedItemsActivity 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 @@ -184,6 +193,9 @@ 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.LocalOpenGraphFetcher +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 @@ -234,11 +246,9 @@ 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.first import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -265,7 +275,6 @@ class ChatActivity : MessagesListAdapter.OnLoadMoreListener, MessagesListAdapter.Formatter, MessagesListAdapter.OnMessageViewLongClickListener, - MessagesListAdapter.OnMessageClickListener, ContentChecker, VoiceMessageInterface, CommonMessageInterface, @@ -280,6 +289,9 @@ class ChatActivity : @Inject lateinit var ncApi: NcApi + @Inject + lateinit var ncApiCoroutines: NcApiCoroutines + @Inject lateinit var permissionUtil: PlatformPermissionUtil @@ -295,7 +307,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 @@ -358,7 +382,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") @@ -378,8 +407,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 @@ -441,15 +482,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 + ) } } @@ -489,6 +530,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?) { @@ -499,6 +551,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( @@ -523,12 +579,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 -> @@ -536,11 +594,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 ) @@ -571,10 +629,232 @@ 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, + LocalOpenGraphFetcher provides { url -> chatViewModel.fetchOpenGraph(url) } + ) { + val isOneToOneConversation = uiState.isOneToOneConversation + Log.d(TAG, "isOneToOneConversation=" + isOneToOneConversation) + + GetNewChatView( + chatItems = uiState.items, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + onLoadMore = { loadMoreMessagesCompose() }, + advanceLocalLastReadMessageIfNeeded = { advanceLocalLastReadMessageIfNeeded(it) }, + updateRemoteLastReadMessageIfNeeded = { updateRemoteLastReadMessageIfNeeded() }, + onLongClick = { openMessageActionsDialog(it) }, + onFileClick = { downloadAndOpenFile(it) }, + onPollClick = { pollId, pollName -> openPollDialog(pollId, pollName) }, + onVoicePlayPauseClick = { onVoicePlayPauseClickCompose(it) }, + onVoiceSeek = { _, progress -> chatViewModel.seekToMediaPlayer(progress) }, + onVoiceSpeedClick = { onVoiceSpeedClickCompose(it) }, + onReactionClick = { messageId, emoji -> handleReactionClick(messageId, emoji) }, + onReactionLongClick = { messageId -> openReactionsDialog(messageId) }, + onOpenThreadClick = { messageId -> openThread(messageId.toLong()) } + ) + } + } + } + + private fun onVoicePlayPauseClickCompose(messageId: Int) { + lifecycleScope.launch { + val isCurrentlyPlaying = chatViewModel.uiState.value.items + .mapNotNull { (it as? ChatViewModel.ChatItem.MessageItem)?.uiMessage } + .firstOrNull { it.id == messageId } + ?.content + ?.let { it as? MessageTypeContent.Voice } + ?.isPlaying ?: false + + val message = chatViewModel.getMessageById(messageId.toLong()).first() + val filename = message.fileParameters.name + if (filename.isEmpty()) { + return@launch + } + + val file = File(context.cacheDir, filename) + if (file.exists()) { + if (isCurrentlyPlaying) { + chatViewModel.pauseMediaPlayer(true) + chatViewModel.pauseVoiceMessageUiState(messageId) + } else { + val uiSpeed = chatViewModel.uiState.value.items + .mapNotNull { (it as? ChatViewModel.ChatItem.MessageItem)?.uiMessage } + .firstOrNull { it.id == messageId } + ?.content + ?.let { it as? MessageTypeContent.Voice } + ?.playbackSpeed ?: PlaybackSpeed.NORMAL + chatViewModel.setPlayBack(uiSpeed) + + val retrieved = appPreferences.getWaveFormFromFile(filename) + if (retrieved.isEmpty()) { + setUpWaveform(message) + } else { + if (message.voiceMessageFloatArray == null || message.voiceMessageFloatArray!!.isEmpty()) { + message.voiceMessageFloatArray = retrieved.toFloatArray() + chatViewModel.syncVoiceMessageUiState(message) + } + startPlayback(file, message) + } + } + } else { + downloadFileToCache(message, true) { + setUpWaveform(message) + } + } + } + } + + private fun onVoiceSpeedClickCompose(messageId: Int) { + val currentSpeed = chatViewModel.uiState.value.items + .mapNotNull { (it as? ChatViewModel.ChatItem.MessageItem)?.uiMessage } + .firstOrNull { it.id == messageId } + ?.content + ?.let { it as? MessageTypeContent.Voice } + ?.playbackSpeed ?: PlaybackSpeed.NORMAL + val nextSpeed = currentSpeed.next() + chatViewModel.setPlayBack(nextSpeed) + appPreferences.savePreferredPlayback(conversationUser!!.userId, nextSpeed) + chatViewModel.setVoiceMessageSpeed(messageId, nextSpeed) + } + + fun downloadAndOpenFile(messageId: Int) { + lifecycleScope.launch { + val chatMessage = chatViewModel.getMessageById(messageId.toLong()).first() + FileViewerUtils(this@ChatActivity, conversationUser).openFile(chatMessage) + } + } + + fun openPollDialog(pollId: String, pollName: String) { + val isOwnerOrModerator = ConversationUtils.isParticipantOwnerOrModerator(currentConversation!!) + PollMainDialogFragment + .newInstance(conversationUser!!, roomToken, isOwnerOrModerator, pollId, pollName) + .show(supportFragmentManager, "PollMainDialogFragment") + } + + private fun handleReactionClick(messageId: Int, emoji: String) { + lifecycleScope.launch { + val chatMessage = chatViewModel.getMessageById(messageId.toLong()).first() + onClickReaction(chatMessage, emoji) + } + } + + private fun openReactionsDialog(messageId: Int) { + lifecycleScope.launch { + val chatMessage = chatViewModel.getMessageById(messageId.toLong()).first() + onLongClickReactions(chatMessage) + } + } + + // 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 { @@ -607,14 +887,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() @@ -660,71 +932,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 && message.systemMessageType != ChatMessage.SystemMessageType.CLEARED_CHAT) { - 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 -> {} } } @@ -750,129 +984,115 @@ 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() - - if (conversationUser?.userId != "?" && - hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MENTION_FLAG) && - !isChatThread() - ) { - binding.chatToolbar.setOnClickListener { _ -> showConversationInfoScreen() } - } - refreshScheduledMessages() + joinRoomWithPassword() - 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() - conversationUser?.let { user -> + 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.fetchUpcomingEvent( + chatViewModel.outOfOfficeStatusOfUser( credentials!!, user.baseUrl!!, - roomToken + state.conversationModel!!.name ) } + } - if (currentConversation?.objectType == ConversationEnums.ObjectType.EVENT && - hasSpreedFeatureCapability( - conversationUser?.capabilities!!.spreedCapability!!, - SpreedFeatures.UNBIND_CONVERSATION - ) - ) { - 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) - } - } + conversationUser?.let { user -> + val credentials = ApiUtils.getCredentials(user.username, user.token) + chatViewModel.fetchUpcomingEvent( + credentials!!, + user.baseUrl!!, + roomToken + ) + } - 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 -> { @@ -1020,7 +1240,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) } @@ -1054,77 +1274,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 { @@ -1150,52 +1299,12 @@ 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) }.collect() } - chatViewModel.reactionDeletedViewState.observe(this) { state -> - when (state) { - is ChatViewModel.ReactionDeletedSuccessState -> { - updateUiToDeleteReaction( - state.reactionDeletedModel.chatMessage, - state.reactionDeletedModel.emoji - ) - } - - else -> {} - } - } - - chatViewModel.reactionAddedViewState.observe(this) { state -> - when (state) { - is ChatViewModel.ReactionAddedSuccessState -> { - updateUiToAddReaction( - state.reactionAddedModel.chatMessage, - state.reactionAddedModel.emoji - ) - } - - else -> {} - } - } - messageInputViewModel.editMessageViewState.observe(this) { state -> when (state) { is MessageInputViewModel.EditMessageSuccessState -> { @@ -1450,11 +1559,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) { @@ -1575,6 +1687,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 @@ -1645,7 +1759,7 @@ class ChatActivity : adapter?.registerViewClickListener( R.id.playPauseBtn ) { _, message -> - val filename = message.selectedIndividualHashMap!!["name"] + val filename = message.fileParameters.name val file = File(context.cacheDir, filename!!) if (file.exists()) { if (message.isPlayingVoiceMessage) { @@ -1676,16 +1790,19 @@ class ChatActivity : } private fun setUpWaveform(message: ChatMessage, thenPlay: Boolean = true, backgroundPlayAllowed: Boolean = false) { - val filename = message.selectedIndividualHashMap!!["name"] + val filename = message.fileParameters.name val file = File(context.cacheDir, filename!!) if (file.exists() && message.voiceMessageFloatArray == null) { message.isDownloadingVoiceMessage = true + chatViewModel.syncVoiceMessageUiState(message) adapter?.update(message) CoroutineScope(Dispatchers.Default).launch { val r = AudioUtils.audioFileToFloatArray(file) appPreferences.saveWaveFormForFile(filename, r.toTypedArray()) message.voiceMessageFloatArray = r withContext(Dispatchers.Main) { + message.isDownloadingVoiceMessage = false + chatViewModel.syncVoiceMessageUiState(message) startPlayback(file, message) } } @@ -1699,6 +1816,7 @@ class ChatActivity : chatViewModel.queueInMediaPlayer(file.canonicalPath, message) chatViewModel.startCyclingMediaPlayer() message.isPlayingVoiceMessage = true + chatViewModel.syncVoiceMessageUiState(message) adapter?.update(message) var pos = adapter?.getMessagePositionById(message.id)?.minus(1) ?: -1 @@ -1709,7 +1827,7 @@ class ChatActivity : if (!nextMessage.isVoiceMessage) break downloadFileToCache(nextMessage, false) { - nextMessage.selectedIndividualHashMap?.get("name")?.let { newFileName -> + nextMessage.fileParameters.name?.let { newFileName -> val newFile = File(context.cacheDir, newFileName) chatViewModel.queueInMediaPlayer(newFile.canonicalPath, nextMessage) } @@ -2157,23 +2275,21 @@ class ChatActivity : funToCallWhenDownloadSuccessful: (() -> Unit) ) { message.isDownloadingVoiceMessage = true + chatViewModel.syncVoiceMessageUiState(message) message.openWhenDownloaded = openWhenDownloaded adapter?.update(message) - val baseUrl = message.activeUser!!.baseUrl - val userId = message.activeUser!!.userId + val baseUrl = conversationUser.baseUrl + val userId = conversationUser.userId val attachmentFolder = CapabilitiesUtil.getAttachmentFolder( - message.activeUser!!.capabilities!! + conversationUser.capabilities!! .spreedCapability!! ) - val fileName = message.selectedIndividualHashMap!!["name"] - var size = message.selectedIndividualHashMap!!["size"] - if (size == null) { - size = "-1" - } - val fileSize = size.toLong() - val fileId = message.selectedIndividualHashMap!!["id"] - val path = message.selectedIndividualHashMap!!["path"] + val fileName = message.fileParameters.name + var fileSize = message.fileParameters.size + + val fileId = message.fileParameters.id + val path = message.fileParameters.path // check if download worker is already running val workers = WorkManager.getInstance( @@ -2198,7 +2314,7 @@ class ChatActivity : .putString(DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, attachmentFolder) .putString(DownloadFileToCacheWorker.KEY_FILE_NAME, fileName) .putString(DownloadFileToCacheWorker.KEY_FILE_PATH, path) - .putLong(DownloadFileToCacheWorker.KEY_FILE_SIZE, fileSize) + .putLong(DownloadFileToCacheWorker.KEY_FILE_SIZE, fileSize!!) .build() val downloadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(DownloadFileToCacheWorker::class.java) @@ -2885,9 +3001,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 = @@ -3014,6 +3171,7 @@ class ChatActivity : private fun setupWebsocket() { if (currentConversation == null || conversationUser == null) { + Log.e(TAG, "setupWebsocket: currentConversation or conversationUser is null") return } @@ -3171,62 +3329,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() @@ -3242,37 +3400,26 @@ class ChatActivity : return layoutManager?.findFirstVisibleItemPosition() == 0 } - private fun setUnreadMessageMarker(chatMessageList: List) { - if (chatMessageList.isNotEmpty()) { - val unreadChatMessage = ChatMessage() - unreadChatMessage.jsonMessageId = UNREAD_MESSAGES_MARKER_ID - unreadChatMessage.actorId = "-1" - unreadChatMessage.timestamp = chatMessageList[0].timestamp - unreadChatMessage.message = context.getString(R.string.nc_new_messages) - adapter?.addToStart(unreadChatMessage, false) - } - } - - 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 { @@ -3280,38 +3427,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] @@ -3332,6 +3448,7 @@ class ChatActivity : previousMessageId = chatMessage.jsonMessageId } + return chatMessageList } private fun getItemFromAdapter(messageId: String): Pair? { @@ -3340,7 +3457,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 { @@ -3369,6 +3486,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 @@ -3376,6 +3524,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) @@ -3935,6 +4085,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 @@ -4068,7 +4267,7 @@ class ChatActivity : chatMessage, conversationUser, participantPermissions.hasReactPermission(), - ncApi + ncApiCoroutines ).show() } @@ -4077,10 +4276,10 @@ class ChatActivity : } override fun onMessageViewLongClick(view: View?, message: IMessage?) { - openMessageActionsDialog(message) + // openMessageActionsDialog(message) } - override fun onMessageClick(message: IMessage) { + fun onMessageClick(message: ChatMessage) { val now = SystemClock.elapsedRealtime() if (now - lastMessageClickTime < ViewConfiguration.getDoubleTapTimeout() && message.id?.equals(lastMessageId) == true @@ -4098,9 +4297,15 @@ class ChatActivity : onOpenMessageActionsDialog(chatMessage) } - private fun openMessageActionsDialog(iMessage: IMessage?) { - val message = iMessage as ChatMessage + // just a temporary helper class to get ChatMessage by id. Should be improved after migrationto Compose + private fun openMessageActionsDialog(messageId: Int) { + this.lifecycleScope.launch { + val chatMessage = chatViewModel.getMessageById(messageId.toLong()).first() + openMessageActionsDialog(chatMessage) + } + } + private fun openMessageActionsDialog(message: ChatMessage) { if (message.isTemporary) { TempMessageActionsDialog( this, @@ -4193,7 +4398,10 @@ class ChatActivity : binding.genericComposeView.apply { val shouldDismiss = mutableStateOf(false) setContent { - DateTimeCompose(bundle).GetDateTimeDialog(shouldDismiss, this@ChatActivity) + DateTimeCompose( + bundle, + chatViewModel + ).GetDateTimeDialog(shouldDismiss, this@ChatActivity) } } } @@ -4224,10 +4432,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)), @@ -4259,7 +4482,7 @@ class ChatActivity : } fun share(message: ChatMessage) { - val filename = message.selectedIndividualHashMap!!["name"] + val filename = message.fileParameters.name path = applicationContext.cacheDir.absolutePath + "/" + filename val shareUri = FileProvider.getUriForFile( this, @@ -4277,7 +4500,7 @@ class ChatActivity : } fun checkIfSharable(message: ChatMessage) { - val filename = message.selectedIndividualHashMap!!["name"] + val filename = message.fileParameters.name path = applicationContext.cacheDir.absolutePath + "/" + filename val file = File(context.cacheDir, filename!!) if (file.exists()) { @@ -4291,7 +4514,7 @@ class ChatActivity : private fun showSaveToStorageWarning(message: ChatMessage) { val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance( - message.selectedIndividualHashMap!!["name"]!! + message.fileParameters.name ) saveFragment.show( supportFragmentManager, @@ -4300,7 +4523,7 @@ class ChatActivity : } fun checkIfSaveable(message: ChatMessage) { - val filename = message.selectedIndividualHashMap!!["name"] + val filename = message.fileParameters.name path = applicationContext.cacheDir.absolutePath + "/" + filename val file = File(context.cacheDir, filename!!) if (file.exists()) { @@ -4329,11 +4552,11 @@ class ChatActivity : if (noteToSelfConversation != null) { var shareUri: Uri? = null - val data: HashMap? + val data: HashMap? var metaData = "" var objectId = "" - if (message.hasFileAttachment()) { - val filename = message.selectedIndividualHashMap!!["name"] + if (message.hasFileAttachment) { + val filename = message.fileParameters.name path = applicationContext.cacheDir.absolutePath + "/" + filename shareUri = FileProvider.getUriForFile( context, @@ -4346,12 +4569,11 @@ class ChatActivity : shareUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION ) - } else if (message.hasGeoLocation()) { - data = message.messageParameters?.get("object") - objectId = data?.get("id")!! - val name = data["name"]!! - val lat = data["latitude"]!! - val lon = data["longitude"]!! + } else if (message.hasGeoLocation) { + objectId = message.geoLocationParameters.id!! + val name = message.geoLocationParameters.name + val lat = message.geoLocationParameters.latitude + val lon = message.geoLocationParameters.longitude metaData = "{\"type\":\"geo-location\",\"id\":\"geo:$lat,$lon\",\"latitude\":\"$lat\"," + "\"longitude\":\"$lon\",\"name\":\"$name\"}" @@ -4462,8 +4684,8 @@ class ChatActivity : } fun openInFilesApp(message: ChatMessage) { - val keyID = message.selectedIndividualHashMap!![PreviewMessageViewHolder.KEY_ID] - val link = message.selectedIndividualHashMap!!["link"] + val keyID = message.fileParameters.id + val link = message.fileParameters.link val fileViewerUtils = FileViewerUtils(this, message.activeUser!!) fileViewerUtils.openFileInFilesApp(link!!, keyID!!) } @@ -4532,45 +4754,6 @@ class ChatActivity : } } - fun updateUiToAddReaction(message: ChatMessage, emoji: String) { - if (message.reactions == null) { - message.reactions = LinkedHashMap() - } - - if (message.reactionsSelf == null) { - message.reactionsSelf = ArrayList() - } - - var amount = message.reactions!![emoji] - if (amount == null) { - amount = 0 - } - message.reactions!![emoji] = amount + 1 - message.reactionsSelf!!.add(emoji) - adapter?.update(message) - } - - fun updateUiToDeleteReaction(message: ChatMessage, emoji: String) { - if (message.reactions == null) { - message.reactions = LinkedHashMap() - } - - if (message.reactionsSelf == null) { - message.reactionsSelf = ArrayList() - } - - var amount = message.reactions!![emoji] - if (amount == null) { - amount = 0 - } - message.reactions!![emoji] = amount - 1 - if (message.reactions!![emoji]!! <= 0) { - message.reactions!!.remove(emoji) - } - message.reactionsSelf!!.remove(emoji) - adapter?.update(message) - } - private fun isShowMessageDeletionButton(message: ChatMessage): Boolean { val isUserAllowedByPrivileges = userAllowedByPrivilages(message) @@ -4605,16 +4788,17 @@ class ChatActivity : return isUserAllowedByPrivileges } + @Deprecated("chatkit") override fun hasContentFor(message: ChatMessage, type: Byte): Boolean = when (type) { - CONTENT_TYPE_LOCATION -> message.hasGeoLocation() + CONTENT_TYPE_LOCATION -> message.hasGeoLocation CONTENT_TYPE_VOICE_MESSAGE -> message.isVoiceMessage - CONTENT_TYPE_POLL -> message.isPoll() + CONTENT_TYPE_POLL -> message.hasPoll CONTENT_TYPE_LINK_PREVIEW -> message.isLinkPreview() CONTENT_TYPE_SYSTEM_MESSAGE -> !TextUtils.isEmpty(message.systemMessage) CONTENT_TYPE_UNREAD_NOTICE_MESSAGE -> message.id == UNREAD_MESSAGES_MARKER_ID.toString() CONTENT_TYPE_CALL_STARTED -> message.id == "-2" - CONTENT_TYPE_DECK_CARD -> message.isDeckCard() + CONTENT_TYPE_DECK_CARD -> message.hasDeckCard else -> false } 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 25907856942..1aba39d20d1 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -1007,6 +1007,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..04a768b4679 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,23 @@ 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 +151,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/io/MediaPlayerManager.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt index d08bb2c0857..f8c0bfd28b5 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.io.File import java.io.FileNotFoundException @@ -89,6 +88,7 @@ class MediaPlayerManager : LifecycleAwareManager { private var currentDataSource: String = "" var mediaPlayerDuration: Int = 0 var mediaPlayerPosition: Int = 0 + private var requestedPlaybackSpeed: PlaybackSpeed? = null /** * Starts playing audio from the given path, initializes or resumes if the player is already created. @@ -237,6 +237,7 @@ class MediaPlayerManager : LifecycleAwareManager { * Sets the player speed. */ fun setPlayBackSpeed(speed: PlaybackSpeed) { + requestedPlaybackSpeed = speed if (mediaPlayer != null && mediaPlayer!!.isPlaying) { mediaPlayer!!.playbackParams.let { params -> params.setSpeed(speed.value) @@ -292,11 +293,16 @@ class MediaPlayerManager : LifecycleAwareManager { currentCycledMessage?.let { it.resetVoiceMessage = true it.isPlayingVoiceMessage = false + it.voiceMessageSeekbarProgress = 0 + it.voiceMessagePlayedSeconds = 0 } - runBlocking { - _mediaPlayerSeekBarPositionMsg.emit(currentCycledMessage!!) - } + val completedMessage = currentCycledMessage currentCycledMessage = null + if (completedMessage != null) { + scope.launch { + _mediaPlayerSeekBarPositionMsg.emit(completedMessage) + } + } loop = false _managerState.value = MediaPlayerManagerState.STOPPED } @@ -311,12 +317,13 @@ class MediaPlayerManager : LifecycleAwareManager { private fun MediaPlayer.onPrepare() { mediaPlayerDuration = this.duration - val playBackSpeed = if (currentCycledMessage?.actorId == null) { - PlaybackSpeed.NORMAL.value - } else { - appPreferences.getPreferredPlayback(currentCycledMessage?.actorId).value - } - mediaPlayer!!.playbackParams.setSpeed(playBackSpeed) + val playBackSpeed = requestedPlaybackSpeed?.value + ?: if (currentCycledMessage?.actorId == null) { + PlaybackSpeed.NORMAL.value + } else { + appPreferences.getPreferredPlayback(currentCycledMessage?.actorId).value + } + mediaPlayer!!.playbackParams = mediaPlayer!!.playbackParams.setSpeed(playBackSpeed) start() _managerState.value = MediaPlayerManagerState.STARTED 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 c1691a3fc95..1c4a4ae30c4 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 @@ -25,8 +25,12 @@ import com.nextcloud.talk.utils.Mimetype 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, @@ -34,8 +38,10 @@ data class ChatMessage( var isFormerOneToOneConversation: Boolean = false, + @Deprecated("should be deleted in long term") var activeUser: User? = null, + @Deprecated("delete with chatkit?") var selectedIndividualHashMap: Map? = null, var isDeleted: Boolean = false, @@ -75,6 +81,7 @@ data class ChatMessage( var parentMessageId: Long? = null, + @Deprecated("delete with chatkit") var readStatus: Enum = ReadStatus.NONE, var messageType: String? = null, @@ -145,11 +152,40 @@ 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 { + val fileParameters by lazy { FileParameters(messageParameters) } + val geoLocationParameters by lazy { GeoLocationParameters(messageParameters) } + val pollParameters by lazy { PollParameters(messageParameters) } + val deckCardParameters by lazy { DeckCardParameters(messageParameters) } + + val hasFileAttachment get() = messageParameters?.containsKey("file") == true + + // val hasGeoLocation get() = messageParameters?.containsKey("geo-location") == true + val hasGeoLocation get() = messageParameters?.get("object")?.get("type") == "geo-location" + val hasPoll get() = messageParameters?.get("object")?.get("type") == "talk-poll" + val hasDeckCard get() = messageParameters?.containsKey("deck-card") == true + + fun getCalculateMessageType(): MessageType { + if (!systemMessage.isNullOrBlank()) return MessageType.SYSTEM_MESSAGE + if (isVoiceMessage) return MessageType.VOICE_MESSAGE + + return when { + hasFileAttachment -> MessageType.SINGLE_NC_ATTACHMENT_MESSAGE + hasGeoLocation -> MessageType.SINGLE_NC_GEOLOCATION_MESSAGE + hasPoll -> MessageType.POLL_MESSAGE + hasDeckCard -> MessageType.DECK_CARD + else -> MessageType.REGULAR_TEXT_MESSAGE + } + } + var extractedUrlToPreview: String? = null // messageTypesToIgnore is weird. must be deleted by refactoring!!! @@ -166,74 +202,51 @@ data class ChatMessage( MessageType.DECK_CARD ) - fun isDeckCard(): Boolean { - if (messageParameters != null && messageParameters!!.size > 0) { - for ((_, individualHashMap) in messageParameters!!) { - if (isHashMapEntryEqualTo(individualHashMap, "type", "deck-card")) { - return true - } - } - } - return false - } + fun extractLinkPreviewUrl(user: User): String? { + if (!CapabilitiesUtil.isLinkPreviewAvailable(user)) return null + val text: CharSequence = StringBuffer(message ?: return null) + val regexOptions = setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE) - fun hasFileAttachment(): Boolean { - if (messageParameters != null && messageParameters!!.size > 0) { - for ((_, individualHashMap) in messageParameters!!) { - if (isHashMapEntryEqualTo(individualHashMap, "type", "file")) { - return true - } - } - } - return false - } - - fun hasGeoLocation(): Boolean { - if (messageParameters != null && messageParameters!!.size > 0) { - for ((_, individualHashMap) in messageParameters!!) { - if (isHashMapEntryEqualTo(individualHashMap, "type", "geo-location")) { - return true - } - } + val regexStringFromServer = user.capabilities?.coreCapability?.referenceRegex + val regexFromServer = regexStringFromServer?.toRegex(regexOptions) + if (regexFromServer != null) { + val match = regexFromServer.find(text)?.groups?.get(0)?.value?.trim() + if (match != null) return match } - return false - } - fun isPoll(): Boolean { - if (messageParameters != null && messageParameters!!.size > 0) { - for ((_, individualHashMap) in messageParameters!!) { - if (isHashMapEntryEqualTo(individualHashMap, "type", "talk-poll")) { - return true - } - } - } - return false + return REGEX_STRING_DEFAULT.toRegex(regexOptions).find(text)?.groups?.get(0)?.value?.trim() } @Suppress("ReturnCount") + @Deprecated("delete with chatkit") 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 } @@ -255,39 +268,24 @@ data class ChatMessage( @Suppress("Detekt.NestedBlockDepth") override fun getImageUrl(): String? { - if (messageParameters != null && messageParameters!!.size > 0) { - for ((_, individualHashMap) in messageParameters!!) { - if (isHashMapEntryEqualTo(individualHashMap, "type", "file")) { - // FIX-ME: this selectedIndividualHashMap stuff needs to be analyzed and most likely be refactored! - // it just feels wrong to fill this here inside getImageUrl() - selectedIndividualHashMap = individualHashMap - if (!isVoiceMessage) { - if (activeUser != null && activeUser!!.baseUrl != null) { - val path = individualHashMap["path"] - if (path != null && activeUser!!.username != null) { - if (shouldAutoplayGif()) { - return ApiUtils.getUrlForFileDownload( - activeUser!!.baseUrl!!, - activeUser!!.username!!, - path - ) - } - } - return ApiUtils.getUrlForFilePreviewWithFileId( - activeUser!!.baseUrl!!, - individualHashMap["id"]!!, - sharedApplication!!.resources.getDimensionPixelSize(R.dimen.maximum_file_preview_size) - ) - } else { - Log.e( - TAG, - "activeUser or activeUser.getBaseUrl() were null when trying to getImageUrl()" - ) - } - } + if (hasFileAttachment) { + if (!isVoiceMessage) { // TODO not yet sure why this check was done + if (activeUser != null && activeUser!!.baseUrl != null) { + return ApiUtils.getUrlForFilePreviewWithFileId( + activeUser!!.baseUrl!!, + fileParameters.id, + sharedApplication!!.resources.getDimensionPixelSize(R.dimen.maximum_file_preview_size) + ) + } else { + Log.e( + TAG, + "activeUser or activeUser.getBaseUrl() were null when trying to getImageUrl()" + ) } } } + + // TODO not yet sure what this is return if (!messageTypesToIgnore.contains(getCalculateMessageType())) { message!!.trim() } else { @@ -295,22 +293,22 @@ data class ChatMessage( } } - fun getCalculateMessageType(): MessageType = - if (!TextUtils.isEmpty(systemMessage)) { - MessageType.SYSTEM_MESSAGE - } else if (isVoiceMessage) { - MessageType.VOICE_MESSAGE - } else if (hasFileAttachment()) { - MessageType.SINGLE_NC_ATTACHMENT_MESSAGE - } else if (hasGeoLocation()) { - MessageType.SINGLE_NC_GEOLOCATION_MESSAGE - } else if (isPoll()) { - MessageType.POLL_MESSAGE - } else if (isDeckCard()) { - MessageType.DECK_CARD - } else { - MessageType.REGULAR_TEXT_MESSAGE - } + // fun getCalculateMessageType(): MessageType = + // if (!TextUtils.isEmpty(systemMessage)) { + // MessageType.SYSTEM_MESSAGE + // } else if (isVoiceMessage) { + // MessageType.VOICE_MESSAGE + // } else if (hasFileAttachment()) { + // MessageType.SINGLE_NC_ATTACHMENT_MESSAGE + // } else if (hasGeoLocation()) { + // MessageType.SINGLE_NC_GEOLOCATION_MESSAGE + // } else if (isPoll()) { + // MessageType.POLL_MESSAGE + // } else if (isDeckCard()) { + // MessageType.DECK_CARD + // } else { + // MessageType.REGULAR_TEXT_MESSAGE + // } override fun getId(): String = jsonMessageId.toString() @@ -390,6 +388,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/model/DeckCardParameters.kt b/app/src/main/java/com/nextcloud/talk/chat/data/model/DeckCardParameters.kt new file mode 100644 index 00000000000..d00ae434ad9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/model/DeckCardParameters.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.data.model + +class DeckCardParameters(messageParameters: HashMap>?) : + RichObjectParameters(messageParameters, "deck-card") { + + val id = string("id") + val name = string("name") + + val boardName = string("boardname") + val stackName = string("stackname") + + val link = string("link") +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/model/FileParameters.kt b/app/src/main/java/com/nextcloud/talk/chat/data/model/FileParameters.kt new file mode 100644 index 00000000000..9a4edc762e5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/model/FileParameters.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.data.model + +class FileParameters(messageParameters: HashMap>?) : + RichObjectParameters(messageParameters, "file") { + + val id = string("id") + val name = string("name") + val path = string("path") + val link = string("link") + val mimetype = string("mimetype") + + val size = long("size") + val mtime = long("mtime") + + val etag = string("etag") + val permissions = int("permissions") + + val width = int("width") + val height = int("height") + + val blurhash = string("blurhash") + + val previewAvailable = yesNo("preview-available") + val hideDownload = yesNo("hide-download") +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/model/GeoLocationParameters.kt b/app/src/main/java/com/nextcloud/talk/chat/data/model/GeoLocationParameters.kt new file mode 100644 index 00000000000..5ff1b4270db --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/model/GeoLocationParameters.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.data.model + +class GeoLocationParameters(messageParameters: HashMap>?) : + RichObjectParameters(messageParameters, "object") { + + val id = string("id") + val name = string("name") + + val latitude = double("latitude") + val longitude = double("longitude") +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/model/PollParameters.kt b/app/src/main/java/com/nextcloud/talk/chat/data/model/PollParameters.kt new file mode 100644 index 00000000000..424a767c2f4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/model/PollParameters.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.data.model + +class PollParameters(messageParameters: HashMap>?) : + RichObjectParameters(messageParameters, "object") { + + val id = string("id") + val name = string("name") +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/model/RichObjectParameters.kt b/app/src/main/java/com/nextcloud/talk/chat/data/model/RichObjectParameters.kt new file mode 100644 index 00000000000..7ba2fd6c905 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/model/RichObjectParameters.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.data.model + +abstract class RichObjectParameters(messageParameters: HashMap>?, key: String) { + protected val params: HashMap? = messageParameters?.get(key) + + protected fun string(name: String): String = params?.get(name).orEmpty() + + protected fun int(name: String): Int? = params?.get(name)?.toIntOrNull() + + protected fun long(name: String): Long? = params?.get(name)?.toLongOrNull() + + protected fun double(name: String): Double? = params?.get(name)?.toDoubleOrNull() + + protected fun yesNo(name: String): Boolean = params?.get(name) == "yes" +} 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 ba7c47172ac..72c8c7cc307 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 @@ -65,7 +65,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..1698706fa3d 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,32 @@ 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.take +import retrofit2.HttpException import java.io.IOException import javax.inject.Inject +import kotlin.collections.map @Suppress("LargeClass", "TooManyFunctions") class OfflineFirstChatRepository @Inject constructor( @@ -86,8 +87,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 +99,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 +119,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 +142,109 @@ 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) + // Problem: before the PR made sure the initial data is displayed. Now nothing is triggered. + // 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 +277,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") - val loadFromServer = hasToLoadPreviousMessagesFromServer(beforeMessageId, DEFAULT_MESSAGES_LIMIT) + 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() + ) - if (loadFromServer) { - Log.d(TAG, "Starting online request for loadMoreMessages") - sync(withNetworkParams) - } + val networkParams = Bundle() + + var showUnreadMessagesMarker = true - showMessagesBefore(internalConversationId, beforeMessageId, DEFAULT_MESSAGES_LIMIT) - updateUiForLastCommonRead() + 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 + ) + + 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 +490,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 +498,181 @@ 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 { + val local = chatDao.getChatMessageEntity(internalConversationId, messageId) - 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) + if (local != null) { + emit(local.toDomainModel()) + return@flow + } + + getAndPersistMessages(bundle) - Log.d(TAG, "Starting online request for single message (e.g. a reply)") - sync(bundle) + emitAll( + observeMessageNonNull(internalConversationId, messageId) + .map { it.toDomainModel() } + .take(1) + ) } - return chatDao.getChatMessageForConversation( - internalConversationId, - messageId - ).map(ChatMessageEntity::asModel) - } + + 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) + ).map(ChatMessageEntity::toDomainModel) + + 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 + // } + } - @Suppress("UNCHECKED_CAST", "MagicNumber", "Detekt.TooGenericExceptionCaught") - private fun getMessagesFromServer(bundle: Bundle): Pair>? { - val fieldMap = bundle.getSerializable(BundleKeys.KEY_FIELD_MAP) as HashMap + 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)) + } - 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() - ) - } - - HTTP_CODE_PRECONDITION_FAILED -> { - Log.d(TAG, "getMessagesFromServer HTTP_CODE_PRECONDITION_FAILED") - - return@map Pair( - HTTP_CODE_PRECONDITION_FAILED, - listOf() - ) - } - - 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") + } + } - return chatMessagesFromSync + 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") + } + + is ChatPullResult.Error -> { + Log.e(TAG, "Error pulling messages from server", result.throwable) + } + } } private suspend fun OfflineFirstChatRepository.updateMessagesData( @@ -642,20 +680,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,39 +717,24 @@ 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, ChatMessage.SystemMessageType.REACTION_REVOKED, - ChatMessage.SystemMessageType.REACTION_DELETED, + ChatMessage.SystemMessageType.REACTION_DELETED -> + // Signaling does not include reactionsSelf; derive it so the self-reaction + // border stays correct regardless of whether signaling or the API response lands first. + upsertParentMessage(messageJson, deriveReactions = true) + ChatMessage.SystemMessageType.MESSAGE_DELETED, ChatMessage.SystemMessageType.POLL_VOTED, - ChatMessage.SystemMessageType.MESSAGE_EDITED -> { - // the parent message is always the newest state, no matter how old the system message is. - // that's why we can just take the parent, update it in DB and update the UI - messageJson.parentMessage?.let { parentMessageJson -> - parentMessageJson.message?.let { - val parentMessageEntity = parentMessageJson.asEntity(currentUser.id!!) - - // Preserve parentMessageId if missing in server response but present in local DB - val existingEntity = - chatDao.getChatMessageEntity(internalConversationId, parentMessageJson.id) - if (existingEntity != null && parentMessageEntity.parentMessageId == null) { - parentMessageEntity.parentMessageId = existingEntity.parentMessageId - } - - chatDao.upsertChatMessage(parentMessageEntity) - _updateMessageFlow.emit(parentMessageEntity.asModel()) - } - } - } + ChatMessage.SystemMessageType.MESSAGE_EDITED -> + upsertParentMessage(messageJson) ChatMessage.SystemMessageType.CLEARED_CHAT -> { // for lookIntoFuture just deleting everything would be fine. @@ -733,6 +752,61 @@ class OfflineFirstChatRepository @Inject constructor( } } + // the parent message is always the newest state, no matter how old the system message is. + // that's why we can just take the parent, update it in DB and update the UI + private suspend fun upsertParentMessage(messageJson: ChatMessageJson, deriveReactions: Boolean = false) { + val parentMessageJson = messageJson.parentMessage ?: return + parentMessageJson.message ?: return + val parentMessageEntity = parentMessageJson.asEntity(currentUser.id!!) + + // Preserve parentMessageId if missing in server response but present in local DB + val existingEntity = chatDao.getChatMessageEntity(internalConversationId, parentMessageJson.id) + if (existingEntity != null && parentMessageEntity.parentMessageId == null) { + parentMessageEntity.parentMessageId = existingEntity.parentMessageId + } + + if (deriveReactions) { + parentMessageEntity.reactionsSelf = + deriveReactionsSelf(messageJson, existingEntity) + } + + chatDao.upsertChatMessage(parentMessageEntity) + } + + /** + * Derives the correct reactionsSelf list for a parent message when a reaction system message arrives via + * signaling. The signaling payload does not include reactionsSelf, so we preserve the existing DB state and + * apply a targeted update only if the actor of the system message is the current user. + * + * The emoji is always in the message field (messageParameters is empty for all reaction system messages). + * + * This handles the race condition where signaling may arrive before or after + * ReactionsRepositoryImpl has written the optimistic local update. + */ + private fun deriveReactionsSelf( + systemMessageJson: ChatMessageJson, + existingEntity: ChatMessageEntity? + ): ArrayList { + val isCurrentUserActor = systemMessageJson.actorId == currentUser.userId && + systemMessageJson.actorType == "users" + + if (!isCurrentUserActor) return existingEntity?.reactionsSelf ?: ArrayList() + + val emoji = systemMessageJson.message ?: return existingEntity?.reactionsSelf ?: ArrayList() + val reactionsSelf = ArrayList(existingEntity?.reactionsSelf ?: emptyList()) + + when (systemMessageJson.systemMessageType) { + ChatMessage.SystemMessageType.REACTION -> { + if (!reactionsSelf.contains(emoji)) reactionsSelf.add(emoji) + } + ChatMessage.SystemMessageType.REACTION_REVOKED, + ChatMessage.SystemMessageType.REACTION_DELETED -> reactionsSelf.remove(emoji) + else -> {} + } + + return reactionsSelf + } + /** * 304 is returned when oldest message of chat was queried or when long polling request returned with no * modification. hasHistory is only set to false, when 304 was returned for the the oldest message @@ -768,7 +842,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 +851,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 +883,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 +899,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 +914,7 @@ class OfflineFirstChatRepository @Inject constructor( messageLimit, threadId ).map { - it.map(ChatMessageEntity::asModel) + it.map(ChatMessageEntity::toDomainModel) }.first() val list = getMessagesBefore( @@ -860,9 +931,6 @@ class OfflineFirstChatRepository @Inject constructor( override fun handleOnPause() { itIsPaused = true - if (this::scope.isInitialized) { - scope.cancel() - } } override fun handleOnResume() { @@ -902,7 +970,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 +1007,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 +1033,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 +1073,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 +1115,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 +1147,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 +1163,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 +1179,50 @@ 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!!) + } + + chatDao.upsertChatMessagesAndDeleteTemp(internalConversationId, chatMessageEntities) + + return chatMessageEntities + } + + override fun observeMessages(internalConversationId: String): Flow> = + chatBlocksDao + .getLatestChatBlock(internalConversationId, threadId) + .distinctUntilChanged() + .flatMapLatest { latestBlock -> + + if (latestBlock == null) { + flowOf(emptyList()) + } else { + chatDao.getMessagesEqualOrNewerThan( + internalConversationId = internalConversationId, + threadId = threadId, + oldestMessageId = latestBlock.oldestMessageId + ) + } + } + @Suppress("LongParameterList") override suspend fun sendScheduledChatMessage( credentials: String, @@ -1159,7 +1270,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 +1293,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 +1357,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 008601d3c41..b36e8c554e1 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 @@ -23,7 +24,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 { @@ -160,11 +160,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..6f40412752b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt @@ -0,0 +1,280 @@ +/* + * 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.Stable +import com.nextcloud.talk.R +import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.database.model.SendStatus +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.DrawableUtils +import com.nextcloud.talk.ui.PlaybackSpeed +import java.time.LocalDate + +// immutable class for chat message UI. only val, no vars! +@Stable // TODO: or @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 threadReplies: Int, + val incoming: Boolean, + val isDeleted: Boolean, + val avatarUrl: String?, + val statusIcon: MessageStatusIcon, + val timestamp: Long, + val date: LocalDate, + val content: MessageTypeContent?, + val roomToken: String? = null, + val activeUserId: String? = null, + val activeUserBaseUrl: String? = null, + val messageParameters: Map> = emptyMap(), + val reactions: List = emptyList(), + val parentMessage: ChatMessageUi? = null +) + +data class MessageReactionUi(val emoji: String, val amount: Int, val isSelfReaction: Boolean) + +sealed interface MessageTypeContent { + object RegularText : MessageTypeContent + object SystemMessage : MessageTypeContent + + data class LinkPreview(val url: String) : MessageTypeContent + + data class Media(val previewUrl: String?, val drawableResourceId: Int) : MessageTypeContent + + data class Geolocation(val id: String, val name: String, 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( + val actorId: String?, + val isPlaying: Boolean, + val wasPlayed: Boolean, + val isDownloading: Boolean, + val durationSeconds: Int, + val playedSeconds: Int, + val seekbarProgress: Int, + val waveform: List, + val playbackSpeed: PlaybackSpeed = PlaybackSpeed.NORMAL + ) : MessageTypeContent +} + +enum class MessageStatusIcon { + FAILED, + SENDING, + READ, + SENT +} + +// Domain model (ChatMessage) to UI model (ChatMessageUi) +fun ChatMessage.toUiModel( + user: User, + 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, + threadReplies = threadReplies ?: 0, + incoming = incoming, + isDeleted = isDeleted, + avatarUrl = avatarUrl, + statusIcon = resolveStatusIcon( + jsonMessageId, + lastCommonReadMessageId, + isTemporary, + sendStatus + ), + timestamp = timestamp, + date = dateKey(), + content = getMessageTypeContent(user, chatMessage), + roomToken = token, + activeUserId = user.userId, + activeUserBaseUrl = user.baseUrl, + messageParameters = normalizeMessageParameters(), + reactions = getReactionUiModels(), + // setting parent message recursively might be a problem regarding recompositions? extract only what is needed + // for UI? + parentMessage = parentMessage?.toUiModel(user, parentMessage, 0, null) + ) + +private fun ChatMessage.normalizeMessageParameters(): Map> = + messageParameters + .orEmpty() + .mapNotNull { (key, params) -> + if (key == null) { + null + } else { + val normalizedParams = params + .orEmpty() + .mapNotNull { (nestedKey, value) -> + if (nestedKey == null || value == null) { + null + } else { + nestedKey to value + } + } + .toMap() + key to normalizedParams + } + } + .toMap() + +private fun ChatMessage.getReactionUiModels(): List { + val selfReactions = reactionsSelf.orEmpty().toSet() + + return reactions.orEmpty() + .filterValues { amount -> amount > 0 } + .map { (emoji, amount) -> + MessageReactionUi( + emoji = emoji, + amount = amount, + isSelfReaction = selfReactions.contains(emoji) + ) + } + .sortedWith(compareByDescending { it.isSelfReaction }.thenByDescending { it.amount }) +} + +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(user: User, message: ChatMessage): MessageTypeContent? = + if (!TextUtils.isEmpty(message.systemMessage)) { + MessageTypeContent.SystemMessage + } else if (message.isVoiceMessage) { + getVoiceContent(message) + } else if (message.hasFileAttachment) { + getMediaContent(user, message) + } else if (message.hasGeoLocation) { + getGeolocationContent(message) + } else if (message.hasPoll) { + getPollContent(message) + } else if (message.hasDeckCard) { + getDeckContent(message) + } else { + message.extractLinkPreviewUrl(user) + ?.let { MessageTypeContent.LinkPreview(url = it) } + ?: MessageTypeContent.RegularText + } + +fun getMediaContent(user: User, message: ChatMessage): MessageTypeContent.Media { + val previewUrl = getPreviewImageUrl(user, message) + val mimetype = message.fileParameters.mimetype + val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(mimetype) + + return MessageTypeContent.Media( + previewUrl, + drawableResourceId + ) + + // use previewAvailable +} + +// fun fetchFileInformation(url: String, activeUser: User?) { +// Single.fromCallable { ReadFilesystemOperation(okHttpClient, activeUser, url, 0) } +// .observeOn(Schedulers.io()) +// .subscribe(object : SingleObserver { +// override fun onSubscribe(d: Disposable) { +// // unused atm +// } +// +// override fun onSuccess(readFilesystemOperation: ReadFilesystemOperation) { +// val davResponse = readFilesystemOperation.readRemotePath() +// if (davResponse.data != null) { +// val browserFileList = davResponse.data as List +// if (browserFileList.isNotEmpty()) { +// Handler(context!!.mainLooper).post { +// val resourceId = getDrawableResourceIdForMimeType(browserFileList[0].mimeType) +// placeholder = ContextCompat.getDrawable(context!!, resourceId) +// } +// } +// } +// } +// +// override fun onError(e: Throwable) { +// Log.e(TAG, "Error reading file information", e) +// } +// }) +// } + +fun getPreviewImageUrl(user: User, message: ChatMessage): String? { + if (message.fileParameters.previewAvailable) { + return ApiUtils.getUrlForFilePreviewWithFileId( + user.baseUrl!!, + message.fileParameters.id, + sharedApplication!!.resources.getDimensionPixelSize(R.dimen.maximum_file_preview_size) // TODO adjust size? + ) + } + return null +} + +fun getGeolocationContent(message: ChatMessage): MessageTypeContent.Geolocation = + MessageTypeContent.Geolocation( + id = message.geoLocationParameters.id, + name = message.geoLocationParameters.name, + lat = message.geoLocationParameters.latitude!!, + lon = message.geoLocationParameters.longitude!! + ) + +fun getPollContent(message: ChatMessage): MessageTypeContent.Poll = + MessageTypeContent.Poll( + pollId = message.pollParameters.id, + pollName = message.pollParameters.name + ) + +fun getDeckContent(message: ChatMessage): MessageTypeContent.Deck = + MessageTypeContent.Deck( + cardName = message.deckCardParameters.name, + stackName = message.deckCardParameters.stackName, + boardName = message.deckCardParameters.boardName, + cardLink = message.deckCardParameters.link + ) + +fun getVoiceContent(message: ChatMessage): MessageTypeContent.Voice = + MessageTypeContent.Voice( + actorId = message.actorId, + isPlaying = message.isPlayingVoiceMessage, + wasPlayed = message.wasPlayedVoiceMessage, + isDownloading = message.isDownloadingVoiceMessage, + durationSeconds = message.voiceMessageDuration, + playedSeconds = message.voiceMessagePlayedSeconds, + seekbarProgress = message.voiceMessageSeekbarProgress, + waveform = message.voiceMessageFloatArray?.toList().orEmpty() + ) 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 2f8d5fbb33b..8b930bc1b3d 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,14 @@ 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.MessageTypeContent +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,9 +41,12 @@ 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.OpenGraphObject import com.nextcloud.talk.models.json.opengraph.Reference import com.nextcloud.talk.models.json.reminder.Reminder import com.nextcloud.talk.models.json.threads.ThreadInfo @@ -51,27 +59,47 @@ 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 import io.reactivex.schedulers.Schedulers -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.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn +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.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull 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, @@ -80,7 +108,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 { @@ -93,14 +124,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() var hiddenUpcomingEvent: String? = null lateinit var participantPermissions: ParticipantPermissions @@ -135,6 +177,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 @@ -185,18 +237,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 @@ -204,15 +246,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 @@ -224,17 +257,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) @@ -307,25 +335,462 @@ 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 isOneToOneConversation: 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() + observeMediaPlayerProgressForCompose() + } + + private fun observeMediaPlayerProgressForCompose() { + mediaPlayerSeekbarObserver + .onEach { message -> + syncVoiceMessageUiState(message) + } + .launchIn(viewModelScope) + } + + fun pauseVoiceMessageUiState(messageId: Int) { + _uiState.update { current -> + val updatedItems = current.items.map { item -> + if (item is ChatItem.MessageItem && item.uiMessage.id == messageId) { + val voiceContent = item.uiMessage.content as? MessageTypeContent.Voice + if (voiceContent != null) { + item.copy(uiMessage = item.uiMessage.copy(content = voiceContent.copy(isPlaying = false))) + } else { + item + } + } else { + item + } + } + current.copy(items = updatedItems) + } + } + + fun setVoiceMessageSpeed(messageId: Int, speed: PlaybackSpeed) { + _uiState.update { current -> + val updatedItems = current.items.map { item -> + if (item is ChatItem.MessageItem && item.uiMessage.id == messageId) { + val voiceContent = item.uiMessage.content as? MessageTypeContent.Voice + if (voiceContent != null) { + item.copy(uiMessage = item.uiMessage.copy(content = voiceContent.copy(playbackSpeed = speed))) + } else { + item + } + } else { + item + } + } + current.copy(items = updatedItems) + } + } + + fun syncVoiceMessageUiState(message: ChatMessage) { + _uiState.update { current -> + val updatedItems = current.items.map { item -> + if (item is ChatItem.MessageItem && item.uiMessage.id == message.jsonMessageId) { + val voiceContent = item.uiMessage.content as? MessageTypeContent.Voice + if (voiceContent != null) { + val updatedVoiceContent = voiceContent.copy( + actorId = message.actorId, + isPlaying = message.isPlayingVoiceMessage, + wasPlayed = message.wasPlayedVoiceMessage, + isDownloading = message.isDownloadingVoiceMessage, + durationSeconds = message.voiceMessageDuration, + playedSeconds = message.voiceMessagePlayedSeconds, + seekbarProgress = message.voiceMessageSeekbarProgress, + waveform = message.voiceMessageFloatArray?.toList() ?: voiceContent.waveform + // playbackSpeed is preserved from existing voiceContent + ) + item.copy(uiMessage = item.uiMessage.copy(content = updatedVoiceContent)) + } else { + item + } + } else { + item + } + } + + current.copy(items = updatedItems) + } + } + + // ------------------------------ + // Observe conversation + // ------------------------------ + private fun observeConversation() { + conversationFlow + .onEach { conversation -> + lastReadMessage = conversation.lastReadMessage + + _uiState.update { current -> + current.copy( + conversation = conversation, + isOneToOneConversation = !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.onStart { emit(0) }) { 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( + currentUser, + 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) + + val isChatRelaySupported = withTimeoutOrNull(WEBSOCKET_CONNECT_TIMEOUT_MS) { + awaitChatRelaySupport(user) + } ?: false + + loadInitialMessages( + withCredentials = credentials, + withUrl = url, + isChatRelaySupported = isChatRelaySupported + ) + + getCapabilities(user, chatRoomToken, conversation) + } + .launchIn(viewModelScope) + } + + fun isChatRelaySupported(user: User): Boolean { + val websocketInstance = WebSocketConnectionHelper.getWebSocketInstanceForUser(user) + return websocketInstance?.supportsChatRelay() == true + } + + private suspend fun awaitChatRelaySupport(user: User): Boolean { + val wsInstance = WebSocketConnectionHelper.getWebSocketInstanceForUser(user) ?: return false + while (!wsInstance.isConnected) { + delay(WEBSOCKET_POLL_INTERVAL_MS) + } + return wsInstance.supportsChatRelay() + } + + 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) } @@ -349,7 +814,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!!) @@ -369,7 +835,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) } @@ -462,7 +931,6 @@ class ChatViewModel @Inject constructor( override fun onNext(t: GenericOverall) { _leaveRoomViewState.value = LeaveRoomSuccessState(funToCallWhenLeaveSuccessful) _getCapabilitiesViewState.value = GetCapabilitiesStartState - _getRoomViewState.value = GetRoomStartState } }) } @@ -530,13 +998,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( @@ -546,15 +1052,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) { @@ -593,8 +1101,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 { @@ -683,35 +1209,23 @@ class ChatViewModel @Inject constructor( chatMessage.id ) - reactionsRepository.deleteReaction( - credentials, - currentUser.id!!, - url, - roomToken, - chatMessage, - emoji - ) - .subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - disposableSet.add(d) - } - - override fun onError(e: Throwable) { - Log.d(TAG, "$e") - } - - override fun onComplete() { - // unused atm - } - - override fun onNext(reactionDeletedModel: ReactionDeletedModel) { - if (reactionDeletedModel.success) { - _reactionDeletedViewState.value = ReactionDeletedSuccessState(reactionDeletedModel) - } + viewModelScope.launch { + try { + val model = reactionsRepository.deleteReaction( + credentials, + currentUser.id!!, + url, + roomToken, + chatMessage, + emoji + ) + if (model.success) { + _reactionDeletedViewState.value = ReactionDeletedSuccessState(model) } - }) + } catch (e: Exception) { + Log.d(TAG, "deleteReaction error: $e") + } + } } fun addReaction(roomToken: String, chatMessage: ChatMessage, emoji: String) { @@ -722,35 +1236,23 @@ class ChatViewModel @Inject constructor( chatMessage.id ) - reactionsRepository.addReaction( - credentials, - currentUser.id!!, - url, - roomToken, - chatMessage, - emoji - ) - .subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - disposableSet.add(d) - } - - override fun onError(e: Throwable) { - Log.d(TAG, "$e") - } - - override fun onComplete() { - // unused atm - } - - override fun onNext(reactionAddedModel: ReactionAddedModel) { - if (reactionAddedModel.success) { - _reactionAddedViewState.value = ReactionAddedSuccessState(reactionAddedModel) - } + viewModelScope.launch { + try { + val model = reactionsRepository.addReaction( + credentials, + currentUser.id!!, + url, + roomToken, + chatMessage, + emoji + ) + if (model.success) { + _reactionAddedViewState.value = ReactionAddedSuccessState(model) } - }) + } catch (e: Exception) { + Log.d(TAG, "addReaction error: $e") + } + } } fun startAudioRecording(context: Context, currentConversation: ConversationModel) { @@ -859,43 +1361,70 @@ 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) @@ -1058,6 +1587,19 @@ class ChatViewModel @Inject constructor( } } + suspend fun fetchOpenGraph(url: String): OpenGraphObject? { + if (!this::currentUser.isInitialized) return null + return withContext(Dispatchers.IO) { + runCatching { + chatNetworkDataSource.getOpenGraph( + currentUser.getCredentials(), + currentUser.baseUrl!!, + url + )?.openGraphObject + }.getOrNull() + } + } + suspend fun updateMessageDraft() { val model = conversationRepository.getLocallyStoredConversation( currentUser, @@ -1147,6 +1689,8 @@ class ChatViewModel @Inject constructor( private val TAG = ChatViewModel::class.simpleName const val JOIN_ROOM_RETRY_COUNT: Long = 3 const val HTTP_CODE_OK: Int = 200 + private const val WEBSOCKET_CONNECT_TIMEOUT_MS = 3000L + private const val WEBSOCKET_POLL_INTERVAL_MS = 50L } sealed class OutOfOfficeUIState { @@ -1172,4 +1716,38 @@ class ChatViewModel @Inject constructor( data class Success(val event: UpcomingEvent) : UpcomingEventUIState() data class Error(val exception: Exception) : UpcomingEventUIState() } + + 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 42881a2b800..2d29376e173 100644 --- a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt @@ -29,7 +29,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 @@ -40,28 +39,34 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp 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 +101,8 @@ fun ContextChatErrorView() { @Composable fun ContextChatSuccessView( + user: User, + viewThemeUtils: ViewThemeUtils, visible: Boolean, context: Context, contextChatRetrieveUiStateSuccess: ContextChatViewModel.ContextChatRetrieveUiState.Success, @@ -168,20 +175,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 isInspection = LocalInspectionMode.current - val adapter = ComposeChatAdapter( - messagesJson = contextChatRetrieveUiStateSuccess.messages, - messageId = messageId, - threadId = threadId, - utils = if (isInspection) previewUtils else null - ) - SideEffect { - adapter.addMessages(messages.toMutableList(), true) - } - adapter.GetView() + + // TODO refactor context chat + // GetNewChatView( + // chatItems = messages, + // conversationThreadId = threadId?.toLong(), + // null + // ) } } } @@ -189,64 +192,64 @@ fun ContextChatSuccessView( } } -@Preview(name = "Light Mode") -@Composable -fun ContextChatSuccessViewPreview(title: String = "Alice") { - ContextChatSuccessView( - visible = true, - context = LocalContext.current, - contextChatRetrieveUiStateSuccess = ContextChatViewModel.ContextChatRetrieveUiState.Success( - messageId = "123", - threadId = null, - messages = emptyList(), - title = title, - subTitle = null - ), - onDismiss = {} - ) -} - -@Preview( - name = "Dark Mode", - uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES -) -@Composable -fun ContextChatSuccessViewDarkPreview() { - ContextChatSuccessViewPreview() -} - -@Preview(name = "RTL / Arabic", locale = "ar") -@Composable -fun ContextChatSuccessViewRtlPreview() { - ContextChatSuccessViewPreview(title = "أليس") -} - -@Preview(name = "Light Mode") -@Composable -fun ContextChatErrorViewPreview() { - val context = LocalContext.current - val colorScheme = ComposePreviewUtils.getInstance(context).viewThemeUtils.getColorScheme(context) - MaterialTheme(colorScheme = colorScheme) { - Surface { - ContextChatErrorView() - } - } -} - -@Preview( - name = "Dark Mode", - uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES -) -@Composable -fun ContextChatErrorViewDarkPreview() { - ContextChatErrorViewPreview() -} - -@Preview(name = "RTL / Arabic", locale = "ar") -@Composable -fun ContextChatErrorViewRtlPreview() { - ContextChatErrorViewPreview() -} +// @Preview(name = "Light Mode") +// @Composable +// fun ContextChatSuccessViewPreview(title: String = "Alice") { +// ContextChatSuccessView( +// visible = true, +// context = LocalContext.current, +// contextChatRetrieveUiStateSuccess = ContextChatViewModel.ContextChatRetrieveUiState.Success( +// messageId = "123", +// threadId = null, +// messages = emptyList(), +// title = title, +// subTitle = null +// ), +// onDismiss = {} +// ) +// } +// +// @Preview( +// name = "Dark Mode", +// uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +// ) +// @Composable +// fun ContextChatSuccessViewDarkPreview() { +// ContextChatSuccessViewPreview() +// } +// +// @Preview(name = "RTL / Arabic", locale = "ar") +// @Composable +// fun ContextChatSuccessViewRtlPreview() { +// ContextChatSuccessViewPreview(title = "أليس") +// } +// +// @Preview(name = "Light Mode") +// @Composable +// fun ContextChatErrorViewPreview() { +// val context = LocalContext.current +// val colorScheme = ComposePreviewUtils.getInstance(context).viewThemeUtils.getColorScheme(context) +// MaterialTheme(colorScheme = colorScheme) { +// Surface { +// ContextChatErrorView() +// } +// } +// } +// +// @Preview( +// name = "Dark Mode", +// uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +// ) +// @Composable +// fun ContextChatErrorViewDarkPreview() { +// ContextChatErrorViewPreview() +// } +// +// @Preview(name = "RTL / Arabic", locale = "ar") +// @Composable +// fun ContextChatErrorViewRtlPreview() { +// ContextChatErrorViewPreview() +// } // This code was written back then but not needed yet, but it's not deleted yet // because it may be used soon when further migrating to Compose... 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 a79c7b78631..ab31d491a14 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.chooseaccount.ChooseAccountShareToDialogFragment import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog @@ -117,7 +114,6 @@ import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.ARCHIVE import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.MENTION import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.UNREAD import com.nextcloud.talk.users.UserManager -import androidx.compose.ui.platform.LocalContext import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.BrandingUtils @@ -160,7 +156,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 @@ -194,9 +189,6 @@ class ConversationsListActivity : @Inject lateinit var networkMonitor: NetworkMonitor - @Inject - lateinit var chatViewModel: ChatViewModel - @Inject lateinit var contactsViewModel: ContactsViewModel @@ -489,55 +481,58 @@ 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, - darkMode = DisplayUtils.isDarkModeOn(LocalContext.current) - ) - 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 + // darkMode = DisplayUtils.isDarkModeOn(LocalContext.current) + // ) + // + // if (duration > 0) { + // BackgroundVoiceMessageCard( + // msg.actorDisplayName!!, + // duration - position, + // offset, + // imageURI, + // conversationImageURI, + // viewThemeUtils, + // context + // ) + // .GetView({ isPaused -> + // if (isPaused) { + // chatViewModel.pauseMediaPlayer(false) + // } else { + // val filename = msg.fileParameters.name + // val file = File(context.cacheDir, filename!!) + // chatViewModel.startMediaPlayer(file.canonicalPath) + // } + // }) { + // chatViewModel.stopMediaPlayer() + // } + // } + // } + // } + // } + // }.collect() + // } } private fun handleNoteToSelfShortcut(noteToSelfAvailable: Boolean, noteToSelfToken: String) { @@ -1463,7 +1458,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 44dd3071749..9fb4dd71d6c 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!!) @@ -158,12 +177,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/RepositoryModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt index 0d669f3c3ec..5dfcd69eb86 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt @@ -101,8 +101,8 @@ class RepositoryModule { ArbitraryStoragesRepositoryImpl(database.arbitraryStoragesDao()) @Provides - fun provideReactionsRepository(ncApi: NcApi, dao: ChatMessagesDao): ReactionsRepository = - ReactionsRepositoryImpl(ncApi, dao) + fun provideReactionsRepository(ncApiCoroutines: NcApiCoroutines, dao: ChatMessagesDao): ReactionsRepository = + ReactionsRepositoryImpl(ncApiCoroutines, dao) @Provides fun provideCallRecordingRepository(ncApi: NcApi): CallRecordingRepository = CallRecordingRepositoryImpl(ncApi) 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 3ef55ea1bf7..7e8139f7678 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 @@ -50,6 +49,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 @@ -127,10 +134,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 @@ -197,3 +204,11 @@ abstract class ViewModelModule { @ViewModelKey(ChooseAccountShareToViewModel::class) abstract fun chooseAccountShareToViewModel(viewModel: ChooseAccountShareToViewModel): 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..96346ba9815 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 @@ -11,6 +11,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import androidx.room.Update import com.nextcloud.talk.data.database.model.ChatMessageEntity import kotlinx.coroutines.flow.Flow @@ -18,16 +19,44 @@ 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) + // AND id >= :oldestMessageId + // 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 getMessagesEqualOrNewerThan( + 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( """ @@ -76,9 +105,21 @@ interface ChatMessagesDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertChatMessages(chatMessages: List) + @Transaction + suspend fun upsertChatMessagesAndDeleteTemp(internalConversationId: String, chatMessages: List) { + upsertChatMessages(chatMessages) + + val referenceIds = chatMessages + .mapNotNull { it.referenceId } + .ifEmpty { return } + + deleteTempChatMessages(internalConversationId, referenceIds) + } + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertChatMessage(chatMessage: ChatMessageEntity) + @Deprecated("use getChatMessageEntity") @Query( """ SELECT * @@ -89,6 +130,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,15 +150,29 @@ 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( - value = """ - DELETE FROM ChatMessages - WHERE internalId in (:internalIds) + """ + SELECT * + FROM ChatMessages + WHERE internalConversationId = :internalConversationId + AND id = :messageId + LIMIT 1 """ ) - fun deleteChatMessages(internalIds: List) + fun observeMessage(internalConversationId: String, messageId: Long): Flow @Query( value = """ 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 78c8c1254b1..6bf6f86c531 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 @@ -70,7 +70,7 @@ fun ConversationModel.asEntity() = hiddenUpcomingEvent = hiddenUpcomingEvent ) -fun ConversationEntity.asModel() = +fun ConversationEntity.toDomainModel() = ConversationModel( internalId = internalId, accountId = accountId, 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 86cc518567d..54a02eed5d0 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 @@ -126,7 +126,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/repositories/reactions/ReactionsRepository.kt b/app/src/main/java/com/nextcloud/talk/repositories/reactions/ReactionsRepository.kt index 55e80688daf..c33539bcb46 100644 --- a/app/src/main/java/com/nextcloud/talk/repositories/reactions/ReactionsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/repositories/reactions/ReactionsRepository.kt @@ -9,27 +9,26 @@ package com.nextcloud.talk.repositories.reactions import com.nextcloud.talk.models.domain.ReactionAddedModel import com.nextcloud.talk.models.domain.ReactionDeletedModel import com.nextcloud.talk.chat.data.model.ChatMessage -import io.reactivex.Observable interface ReactionsRepository { @Suppress("LongParameterList") - fun addReaction( + suspend fun addReaction( credentials: String?, userId: Long, url: String, roomToken: String, message: ChatMessage, emoji: String - ): Observable + ): ReactionAddedModel @Suppress("LongParameterList") - fun deleteReaction( + suspend fun deleteReaction( credentials: String?, userId: Long, url: String, roomToken: String, message: ChatMessage, emoji: String - ): Observable + ): ReactionDeletedModel } diff --git a/app/src/main/java/com/nextcloud/talk/repositories/reactions/ReactionsRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/repositories/reactions/ReactionsRepositoryImpl.kt index 2fe25bd3426..7cfb251dac1 100644 --- a/app/src/main/java/com/nextcloud/talk/repositories/reactions/ReactionsRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/repositories/reactions/ReactionsRepositoryImpl.kt @@ -6,154 +6,87 @@ */ package com.nextcloud.talk.repositories.reactions -import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.data.database.dao.ChatMessagesDao import com.nextcloud.talk.models.domain.ReactionAddedModel import com.nextcloud.talk.models.domain.ReactionDeletedModel -import com.nextcloud.talk.models.json.generic.GenericMeta -import io.reactivex.Observable -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import javax.inject.Inject -class ReactionsRepositoryImpl @Inject constructor(private val ncApi: NcApi, private val dao: ChatMessagesDao) : - ReactionsRepository { +class ReactionsRepositoryImpl @Inject constructor( + private val ncApiCoroutines: NcApiCoroutines, + private val dao: ChatMessagesDao +) : ReactionsRepository { - override fun addReaction( + override suspend fun addReaction( credentials: String?, userId: Long, url: String, roomToken: String, message: ChatMessage, emoji: String - ): Observable { - return ncApi.sendReaction( - credentials, - url, - emoji - ).map { - val model = mapToReactionAddedModel(message, emoji, it.ocs?.meta!!) - persistAddedModel( - userId, - model, - roomToken - ) - return@map model - } + ): ReactionAddedModel { + val response = ncApiCoroutines.sendReaction(credentials, url, emoji) + val model = ReactionAddedModel( + message, + emoji, + response.ocs?.meta?.statusCode == HTTP_CREATED + ) + persistAddedModel(userId, model, roomToken) + return model } - override fun deleteReaction( + override suspend fun deleteReaction( credentials: String?, userId: Long, url: String, roomToken: String, message: ChatMessage, emoji: String - ): Observable { - return ncApi.deleteReaction( - credentials, - url, - emoji - ).map { - val model = mapToReactionDeletedModel(message, emoji, it.ocs?.meta!!) - persistDeletedModel( - userId, - model, - roomToken - ) - return@map model - } - } - - private fun mapToReactionAddedModel( - message: ChatMessage, - emoji: String, - reactionResponse: GenericMeta - ): ReactionAddedModel { - val success = reactionResponse.statusCode == HTTP_CREATED - return ReactionAddedModel( - message, - emoji, - success - ) - } - - private fun mapToReactionDeletedModel( - message: ChatMessage, - emoji: String, - reactionResponse: GenericMeta ): ReactionDeletedModel { - val success = reactionResponse.statusCode == HTTP_OK - return ReactionDeletedModel( + val response = ncApiCoroutines.deleteReaction(credentials, url, emoji) + val model = ReactionDeletedModel( message, emoji, - success + response.ocs?.meta?.statusCode == HTTP_OK ) + persistDeletedModel(userId, model, roomToken) + return model } - private fun persistAddedModel(userId: Long, model: ReactionAddedModel, roomToken: String) = - CoroutineScope(Dispatchers.IO).launch { - // 1. Call DAO, Get a singular ChatMessageEntity with model.chatMessage.{PARAM} - val id = model.chatMessage.jsonMessageId.toLong() - val internalConversationId = "$userId@$roomToken" - val emoji = model.emoji - - val message = dao.getChatMessageForConversation( - internalConversationId, - id - ).first() + private suspend fun persistAddedModel(userId: Long, model: ReactionAddedModel, roomToken: String) { + val id = model.chatMessage.jsonMessageId.toLong() + val internalConversationId = "$userId@$roomToken" + val emoji = model.emoji - // 2. Check state of entity, create params as needed - if (message.reactions == null) { - message.reactions = LinkedHashMap() - } + val message = dao.getChatMessageEntity(internalConversationId, id) ?: return - if (message.reactionsSelf == null) { - message.reactionsSelf = ArrayList() - } + val reactions = message.reactions ?: LinkedHashMap().also { message.reactions = it } + val reactionsSelf = message.reactionsSelf ?: ArrayList().also { message.reactionsSelf = it } - var amount = message.reactions!![emoji] - if (amount == null) { - amount = 0 - } - message.reactions!![emoji] = amount + 1 - message.reactionsSelf!!.add(emoji) - - // 3. Call DAO again, to update the singular ChatMessageEntity with params + if (!reactionsSelf.contains(emoji)) { + reactions[emoji] = reactions.getOrDefault(emoji, 0) + 1 + reactionsSelf.add(emoji) dao.updateChatMessage(message) } + } - private fun persistDeletedModel(userId: Long, model: ReactionDeletedModel, roomToken: String) = - CoroutineScope(Dispatchers.IO).launch { - // 1. Call DAO, Get a singular ChatMessageEntity with model.chatMessage.{PARAM} - val id = model.chatMessage.jsonMessageId.toLong() - val internalConversationId = "$userId@$roomToken" - val emoji = model.emoji - - val message = dao.getChatMessageForConversation(internalConversationId, id).first() - - // 2. Check state of entity, create params as needed - if (message.reactions == null) { - message.reactions = LinkedHashMap() - } + private suspend fun persistDeletedModel(userId: Long, model: ReactionDeletedModel, roomToken: String) { + val id = model.chatMessage.jsonMessageId.toLong() + val internalConversationId = "$userId@$roomToken" + val emoji = model.emoji - if (message.reactionsSelf == null) { - message.reactionsSelf = ArrayList() - } + val message = dao.getChatMessageEntity(internalConversationId, id) ?: return - var amount = message.reactions!![emoji] - if (amount == null) { - amount = 0 - } - message.reactions!![emoji] = amount - 1 - message.reactionsSelf!!.remove(emoji) + val reactions = message.reactions ?: LinkedHashMap().also { message.reactions = it } + val reactionsSelf = message.reactionsSelf ?: ArrayList().also { message.reactionsSelf = it } - // 3. Call DAO again, to update the singular ChatMessageEntity with params + if (reactionsSelf.contains(emoji)) { + reactions[emoji] = (reactions.getOrDefault(emoji, 0) - 1).coerceAtLeast(0) + reactionsSelf.remove(emoji) dao.updateChatMessage(message) } + } companion object { private const val HTTP_OK: Int = 200 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/shareditems/adapters/SharedItemsViewHolder.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt index c4abfdeb482..266c44c8e81 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt @@ -69,11 +69,11 @@ abstract class SharedItemsViewHolder( item.link, item.mimeType ), - FileViewerUtils.ProgressUi( - progressBar, - null, - image - ), + // FileViewerUtils.ProgressUi( + // progressBar, + // null, + // image + // ), true ) } 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 3bc51882725..00000000000 --- a/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt +++ /dev/null @@ -1,1180 +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.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.graphics.vector.ImageVector -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.res.vectorResource -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.ApiUtils -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 androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.offset -import co.touchlab.kermit.Logger -import org.maplibre.compose.camera.CameraPosition -import org.maplibre.compose.camera.rememberCameraState -import org.maplibre.compose.map.MaplibreMap -import org.maplibre.compose.style.BaseStyle -import org.maplibre.spatialk.geojson.Position -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, DisplayUtils.isDarkModeOn(LocalContext.current)) - } - 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 isGif = message.shouldAutoplayGif() - val authHeader = if (isGif) { - ApiUtils.getCredentials(currentUser.username, currentUser.token) - } else { - null - } - val loadedImage = load( - imageUri, - LocalContext.current, - drawableResourceId, - animated = isGif, - authHeader = authHeader - ) - - 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( - ImageVector.vectorResource(R.drawable.ic_baseline_play_arrow_voice_message_24), - contentDescription = stringResource(R.string.play_pause_voice_message), - 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) { - val styleUri = if (isSystemInDarkTheme()) "asset://map_style_dark.json" else "asset://map_style_light.json" - val cameraState = rememberCameraState( - firstPosition = CameraPosition( - target = Position(longitude, latitude), - zoom = MAP_ZOOM - ) - ) - Box( - modifier = Modifier - .heightIn(max = 200.dp) - .fillMaxWidth() - ) { - MaplibreMap( - modifier = Modifier.fillMaxSize(), - baseStyle = BaseStyle.Uri(styleUri), - cameraState = cameraState, - logger = remember { Logger.withTag("MapLibre/Chat") }, - onMapLoadFailed = { reason -> - Log.e("MapLibre/Chat", "Style failed to load: $reason | styleUri=$styleUri") - }, - onMapLoadFinished = { - Log.d("MapLibre/Chat", "Style loaded successfully: $styleUri") - } - ) - androidx.compose.foundation.Image( - painter = painterResource(R.drawable.ic_baseline_location_on_red_24), - contentDescription = null, - modifier = Modifier - .size(width = 30.dp, height = 50.dp) - .align(Alignment.Center) - .offset(y = (-20).dp) - ) - } - } - - @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\nNextcloud" - 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 = 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 - }, - ChatMessage().apply { - jsonMessageId = 7 - actorId = "user1_id" - actorDisplayName = "User2" - message = "geo-location" - timestamp = System.currentTimeMillis() - messageParameters = hashMapOf( - "geo1" to hashMapOf( - "type" to "geo-location", - "latitude" to "52.5163", - "longitude" to "13.3777", - "name" to "Brandenburg Gate, Berlin" - ) - ) - } - ) - } - - 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..76bdee59082 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt @@ -0,0 +1,998 @@ +/* + * 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.content.Context +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.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +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.fillMaxWidth +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 +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.foundation.rememberScrollState +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.platform.LocalInspectionMode +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.ColorUtils +import coil.compose.AsyncImage +import com.nextcloud.talk.R +import com.nextcloud.talk.adapters.messages.ThreadButtonComposable +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageReactionUi +import com.nextcloud.talk.chat.ui.model.MessageStatusIcon +import com.nextcloud.talk.contacts.loadImage +import com.nextcloud.talk.ui.theme.LocalViewThemeUtils +import com.nextcloud.talk.utils.DateUtils +import com.nextcloud.talk.utils.DisplayUtils +import java.time.LocalDate + +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 HALF_OPACITY = 127 +private const val MESSAGE_LENGTH_THRESHOLD = 25 +private const val ANIMATED_BLINK = 500 + +private val BUBBLE_RADIUS_BIG = 10.dp +private val BUBBLE_RADIUS_SMALL = 2.dp + +private val REACTION_RADIUS = 8.dp + +internal val LocalReactionClickHandler = compositionLocalOf<(Int, String) -> Unit> { { _, _ -> } } +internal val LocalReactionLongClickHandler = compositionLocalOf<(Int) -> Unit> { {} } +internal val LocalOpenThreadHandler = compositionLocalOf<(Int) -> Unit> { {} } + +private enum class MetadataLayoutMode { + CAPTION, + OVERLAY, + INLINE, + BELOW +} + +private fun resolveMetadataLayoutMode( + captionText: String?, + forceTimeOverlay: Boolean, + showInlineMetadata: Boolean +): MetadataLayoutMode = + when { + captionText != null -> MetadataLayoutMode.CAPTION + forceTimeOverlay -> MetadataLayoutMode.OVERLAY + showInlineMetadata -> MetadataLayoutMode.INLINE + else -> MetadataLayoutMode.BELOW + } + +private fun shouldShowTimeNextToContent( + message: ChatMessageUi, + forceTimeBelow: Boolean, + forceTimeOverlay: Boolean +): Boolean { + if (forceTimeBelow || forceTimeOverlay) return false + if (message.hasMentionChips()) return false + val containsLinebreak = message.message.contains("\n") || message.message.contains("\r") + return (message.message.length < MESSAGE_LENGTH_THRESHOLD) && !containsLinebreak +} + +private fun ChatMessageUi.hasMentionChips(): Boolean = + messageParameters.any { (key, parameter) -> + message.contains("{$key}") && parameter["type"] in setOf("user", "guest", "call", "user-group", "email", "circle") + } + +@Composable +fun MessageScaffold( + uiMessage: ChatMessageUi, + conversationThreadId: Long? = null, + includePadding: Boolean = true, + isOneToOneConversation: Boolean = true, + captionText: String? = null, + playAnimation: Boolean = false, + forceTimeBelow: Boolean = false, + forceTimeOverlay: Boolean = false, + bubbleColor: Color? = null, + content: @Composable () -> Unit +) { + val incoming = uiMessage.incoming + val resolvedBubbleColor = bubbleColor ?: run { + val context = LocalContext.current + if (incoming) { + val colorRes = if (uiMessage.isDeleted) { + R.color.bg_message_list_incoming_bubble_deleted + } else { + R.color.bg_message_list_incoming_bubble + } + Color(getColorFromTheme(context, colorRes)) + } else { + if (LocalInspectionMode.current) { + colorScheme.primaryContainer + } else { + val viewThemeUtils = LocalViewThemeUtils.current + val colorInt = viewThemeUtils.talk + .getOutgoingMessageBubbleColor(context, uiMessage.isDeleted, false) + + if (uiMessage.isDeleted) { + Color(ColorUtils.setAlphaComponent(colorInt, HALF_OPACITY)) + } else { + Color(colorInt) + } + } + } + } + + val showInlineMetadata = shouldShowTimeNextToContent( + message = uiMessage, + forceTimeBelow = forceTimeBelow, + forceTimeOverlay = forceTimeOverlay + ) + val metadataLayoutMode = resolveMetadataLayoutMode( + captionText = captionText, + forceTimeOverlay = forceTimeOverlay, + showInlineMetadata = showInlineMetadata + ) + + val shape = if (incoming) { + RoundedCornerShape( + topStart = BUBBLE_RADIUS_SMALL, + topEnd = BUBBLE_RADIUS_BIG, + bottomEnd = BUBBLE_RADIUS_BIG, + bottomStart = BUBBLE_RADIUS_BIG + ) + } else { + RoundedCornerShape( + topStart = BUBBLE_RADIUS_BIG, + topEnd = BUBBLE_RADIUS_SMALL, + bottomEnd = BUBBLE_RADIUS_BIG, + bottomStart = BUBBLE_RADIUS_BIG + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (incoming) Arrangement.Start else Arrangement.End + ) { + MessageLeadingDecoration( + uiMessage = uiMessage, + isOneToOneConversation = isOneToOneConversation + ) + MessageBubbleWithReactions( + uiMessage = uiMessage, + incoming = incoming, + includePadding = includePadding, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + shape = shape, + resolvedBubbleColor = resolvedBubbleColor, + metadataLayoutMode = metadataLayoutMode, + captionText = captionText, + showInlineMetadata = showInlineMetadata, + content = content + ) + } +} + +@Composable +private fun RowScope.MessageLeadingDecoration(uiMessage: ChatMessageUi, isOneToOneConversation: Boolean) { + if (uiMessage.incoming && isOneToOneConversation) { + 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 if (uiMessage.incoming) { + Spacer(Modifier.width(8.dp)) + } +} + +@Composable +private fun MessageBubbleWithReactions( + uiMessage: ChatMessageUi, + incoming: Boolean, + includePadding: Boolean, + isOneToOneConversation: Boolean, + conversationThreadId: Long?, + shape: RoundedCornerShape, + resolvedBubbleColor: Color, + metadataLayoutMode: MetadataLayoutMode, + captionText: String?, + showInlineMetadata: Boolean, + content: @Composable () -> Unit +) { + val bubbleModifier = Modifier + .defaultMinSize(60.dp, 40.dp) + .widthIn(60.dp, 280.dp) + + Column(horizontalAlignment = if (incoming) Alignment.Start else Alignment.End) { + Surface( + modifier = bubbleModifier, + color = resolvedBubbleColor, + shape = shape + ) { + MessageBubbleContent( + uiMessage = uiMessage, + includePadding = includePadding, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + metadataLayoutMode = metadataLayoutMode, + captionText = captionText, + showInlineMetadata = showInlineMetadata, + content = content + ) + } + } +} + +@Composable +private fun MessageBubbleContent( + uiMessage: ChatMessageUi, + includePadding: Boolean, + isOneToOneConversation: Boolean, + conversationThreadId: Long?, + metadataLayoutMode: MetadataLayoutMode, + captionText: String?, + showInlineMetadata: Boolean, + content: @Composable () -> Unit +) { + val bubbleContentModifier = if (includePadding) { + Modifier.padding(10.dp, 4.dp, 10.dp, 4.dp) + } else { + Modifier + } + + val hasReactionsOrThread = uiMessage.reactions.isNotEmpty() || + isFirstMessageOfThreadInNormalChat(uiMessage, conversationThreadId) + + Column( + modifier = bubbleContentModifier, + verticalArrangement = Arrangement.Center + ) { + MessageHeader( + uiMessage = uiMessage, + paddingAlreadyApplied = includePadding, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId + ) + MessageBodyWithMetadata( + uiMessage = uiMessage, + metadataLayoutMode = metadataLayoutMode, + captionText = captionText, + showInlineMetadata = showInlineMetadata, + suppressMetadata = hasReactionsOrThread, + content = content + ) + MessageReactions( + uiMessage = uiMessage, + conversationThreadId = conversationThreadId, + fillWidth = metadataLayoutMode == MetadataLayoutMode.BELOW || + metadataLayoutMode == MetadataLayoutMode.OVERLAY || + metadataLayoutMode == MetadataLayoutMode.CAPTION, + addHorizontalPadding = !includePadding + ) + } +} + +@Composable +private fun MessageHeader( + uiMessage: ChatMessageUi, + paddingAlreadyApplied: Boolean, + isOneToOneConversation: Boolean, + conversationThreadId: Long? +) { + uiMessage.parentMessage?.let { + if (it.id.toLong() != conversationThreadId) { + CommonMessageQuote(it) + } + } + + if (uiMessage.incoming && isOneToOneConversation) { + // we only need padding for the author if we did not already apply a padding + val authorStartPadding = if (paddingAlreadyApplied) 0.dp else 10.dp + Text( + uiMessage.actorDisplayName, + fontSize = AUTHOR_TEXT_SIZE, + color = colorScheme.onSurfaceVariant, + modifier = Modifier.padding(authorStartPadding, 4.dp, 6.dp, 4.dp) + ) + } + + ThreadTitle( + message = uiMessage, + conversationThreadId = conversationThreadId, + padding = if (paddingAlreadyApplied) 0.dp else 8.dp + ) +} + +@Composable +private fun ColumnScope.MessageBodyWithMetadata( + uiMessage: ChatMessageUi, + metadataLayoutMode: MetadataLayoutMode, + captionText: String?, + showInlineMetadata: Boolean, + suppressMetadata: Boolean = false, + content: @Composable () -> Unit +) { + when (metadataLayoutMode) { + MetadataLayoutMode.CAPTION -> { + content() + CaptionWithMetadata( + captionText = captionText.orEmpty(), + uiMessage = uiMessage, + showInlineMetadata = showInlineMetadata, + suppressMetadata = suppressMetadata + ) + } + + MetadataLayoutMode.OVERLAY -> { + Box(modifier = Modifier.fillMaxWidth()) { + content() + if (!suppressMetadata) { + OverlayMetadataBadge(uiMessage = uiMessage) + } + } + } + + MetadataLayoutMode.INLINE -> { + if (suppressMetadata) { + Box(modifier = Modifier.padding(bottom = 5.dp)) { + content() + } + } else { + Row( + modifier = Modifier.align(Alignment.End), + verticalAlignment = Alignment.Bottom + ) { + Box(modifier = Modifier.padding(bottom = 5.dp)) { + content() + } + MessageMetadata(uiMessage) + } + } + } + + MetadataLayoutMode.BELOW -> { + Row(verticalAlignment = Alignment.CenterVertically) { + content() + } + if (!suppressMetadata) { + Row( + modifier = Modifier + .align(Alignment.End) + .padding(top = 8.dp, bottom = 5.dp, end = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MessageMetadata(uiMessage) + } + } + } + } +} + +@Composable +private fun BoxScope.OverlayMetadataBadge(uiMessage: ChatMessageUi) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 8.dp, end = 8.dp) + .background(Color.Black.copy(alpha = 0.4f), RoundedCornerShape(10.dp)) + ) { + MessageMetadata(uiMessage = uiMessage, color = Color.White) + } +} + +@Composable +private fun MessageReactions( + uiMessage: ChatMessageUi, + conversationThreadId: Long?, + fillWidth: Boolean = false, + addHorizontalPadding: Boolean = false +) { + val showThreadButton = isFirstMessageOfThreadInNormalChat(uiMessage, conversationThreadId) + if (!showThreadButton && uiMessage.reactions.isEmpty()) { + return + } + + val onReactionClick = LocalReactionClickHandler.current + val onReactionLongClick = LocalReactionLongClickHandler.current + val onOpenThread = LocalOpenThreadHandler.current + + Row( + modifier = run { + var mod = Modifier as Modifier + if (fillWidth) mod = mod.fillMaxWidth() + if (addHorizontalPadding) mod = mod.padding(horizontal = 10.dp) + mod.padding(top = 6.dp, bottom = 5.dp) + }, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = if (fillWidth) { + Modifier.weight(1f).horizontalScroll(rememberScrollState()) + } else { + Modifier.horizontalScroll(rememberScrollState()) + }, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (showThreadButton) { + ThreadButtonComposable( + replyAmount = uiMessage.threadReplies, + onButtonClick = { onOpenThread(uiMessage.id) } + ) + } + uiMessage.reactions.forEach { reaction -> + MessageReactionChip( + messageId = uiMessage.id, + incoming = uiMessage.incoming, + reaction = reaction, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick + ) + } + } + MessageMetadata(uiMessage) + } +} + +@Composable +private fun MessageReactionChip( + messageId: Int, + incoming: Boolean, + reaction: MessageReactionUi, + onReactionClick: (Int, String) -> Unit, + onReactionLongClick: (Int) -> Unit +) { + val themedColors = LocalViewThemeUtils.current.getColorScheme(LocalContext.current) + + val backgroundColor = if (reaction.isSelfReaction) { + themedColors.primaryContainer + } else { + colorResource(R.color.bg_message_list_incoming_bubble) + } + val borderColor = if (reaction.isSelfReaction) { + themedColors.primary + } else { + themedColors.surface + } + val textColor = if (incoming || reaction.isSelfReaction) { + colorResource(R.color.high_emphasis_text) + } else { + themedColors.onSurfaceVariant + } + + Row( + modifier = Modifier + .border(1.5.dp, borderColor, RoundedCornerShape(REACTION_RADIUS)) + .background(backgroundColor, RoundedCornerShape(REACTION_RADIUS)) + .combinedClickable( + onClick = { onReactionClick(messageId, reaction.emoji) }, + onLongClick = { onReactionLongClick(messageId) } + ) + .padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = reaction.emoji, + color = textColor + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = reaction.amount.toString(), + color = textColor, + fontSize = AUTHOR_TEXT_SIZE + ) + } +} + +@Composable +private fun MessageMetadata(uiMessage: ChatMessageUi, color: Color = colorScheme.onSurfaceVariant) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + modifier = Modifier + .padding( + start = 10.dp, + end = 10.dp + ) + ) { + TimeDisplay(uiMessage, color) + if (!uiMessage.incoming) { + ReadStatus(uiMessage, color) + } + } +} + +@Composable +private fun ColumnScope.CaptionWithMetadata( + captionText: String, + uiMessage: ChatMessageUi, + showInlineMetadata: Boolean, + suppressMetadata: Boolean = false +) { + if (!suppressMetadata && showInlineMetadata) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom + ) { + EnrichedText( + uiMessage, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) + Row(verticalAlignment = Alignment.CenterVertically) { + MessageMetadata(uiMessage) + } + } + } else { + EnrichedText( + uiMessage, + modifier = Modifier + // .widthIn(20.dp, 280.dp) + .padding(start = 8.dp, end = 8.dp) + ) + if (!suppressMetadata) { + Row( + modifier = Modifier + .align(Alignment.End) + .padding(bottom = 8.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MessageMetadata(uiMessage) + } + } + } +} + +@Composable +fun CommonMessageQuote(message: ChatMessageUi) { + 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) + EnrichedText( + message, + Modifier.padding(start = 10.dp) + ) + } + } +} + +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, color: Color = colorScheme.onSurfaceVariant) { + val context = LocalContext.current + val timeString = remember(message.timestamp) { + try { + DateUtils(context).getLocalTimeStringFromTimestamp(message.timestamp) + } catch (e: Exception) { + "10:00" + } + } + Text( + timeString, + fontSize = TIME_TEXT_SIZE, + textAlign = TextAlign.Center, + color = color + ) +} + +@Composable +fun ReadStatus(message: ChatMessageUi, color: Color = colorScheme.onSurfaceVariant) { + 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 = color + ) +} + +@Composable +fun ThreadTitle( + message: ChatMessageUi, + conversationThreadId: Long? = null, + padding: androidx.compose.ui.unit.Dp = 0.dp +) { + if (isFirstMessageOfThreadInNormalChat(message, conversationThreadId)) { + Row( + modifier = Modifier + .padding(horizontal = padding, vertical = 10.dp) + ) { + val threadIcon = painterResource(R.drawable.outline_forum_24) + Icon( + threadIcon, + "", + 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, modifier: Modifier) { + MentionEnrichedText( + message = message, + modifier = modifier, + textStyle = TextStyle( + fontSize = REGULAR_TEXT_SIZE, + lineHeight = REGULAR_TEXT_SIZE * LINE_SPACING + ) + ) +} + +fun AnnotatedString.Builder.appendMarkdownWithLinks(text: String) { + val regex = Regex( + pattern = """(\*\*.*?\*\*|\*.*?\*|`.*?`|\[.*?\]\(.*?\)|https?://\S+)""" + ) + + var lastIndex = 0 + + for (match in regex.findAll(text)) { + val range = match.range + + // Append normal text before match + if (lastIndex < range.first) { + append(text.substring(lastIndex, range.first)) + } + + val token = match.value + + when { + // **bold** + token.startsWith("**") -> { + val content = token.removeSurrounding("**") + val start = length + append(content) + addStyle( + SpanStyle(fontWeight = FontWeight.Bold), + start, + length + ) + } + + // *italic* + token.startsWith("*") -> { + val content = token.removeSurrounding("*") + val start = length + append(content) + addStyle( + SpanStyle(fontStyle = FontStyle.Italic), + start, + length + ) + } + + // `code` + token.startsWith("`") -> { + val content = token.removeSurrounding("`") + val start = length + append(content) + addStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + background = Color.LightGray + ), + start, + length + ) + } + + // [text](url) + token.startsWith("[") -> { + val textPart = token.substringAfter("[").substringBefore("]") + val url = token.substringAfter("(").substringBefore(")") + + val start = length + append(textPart) + + addStyle( + SpanStyle( + color = Color.Blue, + textDecoration = TextDecoration.Underline + ), + start, + length + ) + + addLink( + LinkAnnotation.Url(url), + start, + length + ) + } + + // plain URL + token.startsWith("http") -> { + val start = length + append(token) + + addStyle( + SpanStyle( + color = Color.Blue, + textDecoration = TextDecoration.Underline + ), + start, + length + ) + + addLink( + LinkAnnotation.Url(token), + start, + length + ) + } + } + + lastIndex = range.last + 1 + } + + // Append remaining text + if (lastIndex < text.length) { + append(text.substring(lastIndex)) + } +} + +fun isFirstMessageOfThreadInNormalChat(message: ChatMessageUi, conversationThreadId: Long?): 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 + ) +} + +@Preview(showBackground = true, name = "Incoming Message") +@Preview( + showBackground = true, + name = "Incoming Message Dark", + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +) +@Composable +private fun MessageScaffoldIncomingPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + val uiMessage = ChatMessageUi( + id = 1, + text = "Hello! How are you?", + message = "Hello! How are you?", + renderMarkdown = false, + actorDisplayName = "John Doe", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = true, + isDeleted = false, + avatarUrl = null, + statusIcon = MessageStatusIcon.SENT, + timestamp = System.currentTimeMillis() / 1000, + date = LocalDate.now(), + content = null + ) + MessageScaffold(uiMessage = uiMessage) { + EnrichedText( + uiMessage, + Modifier.padding(start = 10.dp) + ) + } + } +} + +@Preview(showBackground = true, name = "Incoming Message") +@Preview( + showBackground = true, + name = "Incoming Message Dark", + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +) +@Composable +private fun MessageScaffoldIncomingLongPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + val uiMessage = ChatMessageUi( + id = 1, + text = "Hello! How are youuuuuuuuuuuuuuuuuuuuuuuuuu?", + message = "Hello! How are youuuuuuuuuuuuuuuuuuuuuuuuuuuuuu?", + renderMarkdown = false, + actorDisplayName = "John Doe", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = true, + isDeleted = false, + avatarUrl = null, + statusIcon = MessageStatusIcon.SENT, + timestamp = System.currentTimeMillis() / 1000, + date = LocalDate.now(), + content = null + ) + MessageScaffold(uiMessage = uiMessage) { + EnrichedText( + uiMessage, + Modifier.padding(start = 10.dp) + ) + } + } +} + +@Preview(showBackground = true, name = "Outgoing Message") +@Preview( + showBackground = true, + name = "Outgoing Message Dark", + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +) +@Composable +private fun MessageScaffoldOutgoingPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + val uiMessage = ChatMessageUi( + id = 2, + text = "I'm doing great, thanks!", + message = "I'm doing great, thanks!", + renderMarkdown = false, + actorDisplayName = "Me", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = false, + isDeleted = false, + avatarUrl = null, + statusIcon = MessageStatusIcon.READ, + timestamp = System.currentTimeMillis() / 1000, + date = LocalDate.now(), + content = null + ) + MessageScaffold(uiMessage = uiMessage) { + EnrichedText( + uiMessage, + Modifier.padding(start = 10.dp) + ) + } + } +} + +@Preview(showBackground = true, name = "Quoted Message") +@Composable +private fun CommonMessageQuotePreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + val uiMessage = ChatMessageUi( + id = 3, + text = "This is a quoted message", + message = "This is a quoted message", + renderMarkdown = false, + actorDisplayName = "Original Author", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = true, + isDeleted = false, + avatarUrl = null, + statusIcon = MessageStatusIcon.SENT, + timestamp = System.currentTimeMillis() / 1000, + date = LocalDate.now(), + content = null + ) + CommonMessageQuote( + message = uiMessage + ) + } +} 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..ae75b11d3f6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt @@ -0,0 +1,337 @@ +/* + * 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.foundation.combinedClickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageReactionUi +import com.nextcloud.talk.chat.ui.model.MessageStatusIcon +import com.nextcloud.talk.chat.ui.model.MessageTypeContent +import java.time.LocalDate + +private val PREVIEW_REACTIONS = listOf( + MessageReactionUi(emoji = "👍", amount = 1, isSelfReaction = true), + MessageReactionUi(emoji = "❤️", amount = 1, isSelfReaction = false) +) + +@Composable +fun ChatMessageView( + message: ChatMessageUi, + isOneToOneConversation: Boolean = false, + conversationThreadId: Long? = null, + onLongClick: ((Int) -> Unit?)? = null, + onFileClick: (Int) -> Unit = {}, + onPollClick: (pollId: String, pollName: String) -> Unit = { _, _ -> }, + onVoicePlayPauseClick: (Int) -> Unit = {}, + onVoiceSeek: (messageId: Int, progress: Int) -> Unit = { _, _ -> }, + onVoiceSpeedClick: (Int) -> Unit = {}, + onReactionClick: (messageId: Int, emoji: String) -> Unit = { _, _ -> }, + onReactionLongClick: (messageId: Int) -> Unit = {}, + onOpenThreadClick: (messageId: Int) -> Unit = {} +) { + CompositionLocalProvider( + LocalReactionClickHandler provides onReactionClick, + LocalReactionLongClickHandler provides onReactionLongClick, + LocalOpenThreadHandler provides onOpenThreadClick + ) { + Box( + modifier = Modifier + .combinedClickable( + onClick = { onLongClick?.invoke(message.id) }, + onLongClick = { onLongClick?.invoke(message.id) } + ) + ) { + when (val content = message.content) { + MessageTypeContent.RegularText -> { + TextMessage( + uiMessage = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId + ) + } + + MessageTypeContent.SystemMessage -> { + SystemMessage(message) + } + + is MessageTypeContent.Media -> { + MediaMessage( + typeContent = content, + message = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + onImageClick = onFileClick + ) + } + + is MessageTypeContent.LinkPreview -> { + LinkMessage( + typeContent = content, + message = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId + ) + } + + is MessageTypeContent.Geolocation -> { + GeolocationMessage( + typeContent = content, + message = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId + ) + } + + is MessageTypeContent.Voice -> { + VoiceMessage( + typeContent = content, + message = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + onPlayPauseClick = onVoicePlayPauseClick, + onSeek = onVoiceSeek, + onSpeedClick = onVoiceSpeedClick + ) + } + + is MessageTypeContent.Poll -> { + PollMessage( + typeContent = content, + message = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + onPollClick = onPollClick + ) + } + + is MessageTypeContent.Deck -> { + DeckMessage( + typeContent = content, + message = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId + ) + } + + else -> { + Log.d("ChatView", "Unknown message type: ${'$'}content") + } + } + } + } +} + +@Preview(showBackground = true, name = "Regular Text") +@Composable +private fun ChatMessageViewRegularTextPreview() { + PreviewContainer { + val uiMessage = createBaseMessage(MessageTypeContent.RegularText) + ChatMessageView(message = uiMessage) + } +} + +@Preview(showBackground = true, name = "Regular Text") +@Composable +private fun ChatMessageViewRegularLongTextPreview() { + PreviewContainer { + val uiMessage = createLongBaseMessage(MessageTypeContent.RegularText) + ChatMessageView(message = uiMessage) + } +} + +@Preview(showBackground = true, name = "System Message") +@Composable +private fun ChatMessageViewSystemMessagePreview() { + PreviewContainer { + val uiMessage = createBaseMessage(MessageTypeContent.SystemMessage) + .copy(text = "You joined the conversation") + ChatMessageView(message = uiMessage) + } +} + +@Preview(showBackground = true, name = "Media Message") +@Composable +private fun ChatMessageViewMediaPreview() { + PreviewContainer { + val uiMessage = createBaseMessage( + MessageTypeContent.Media( + previewUrl = null, + drawableResourceId = R.drawable.ic_mimetype_image + ) + ) + ChatMessageView(message = uiMessage) + } +} + +@Preview(showBackground = true, name = "Media Message") +@Composable +private fun ChatMessageViewMediaPreviewWithoutCaption() { + PreviewContainer { + val uiMessage = createBaseMessageWithoutCaption( + MessageTypeContent.Media( + previewUrl = null, + drawableResourceId = R.drawable.ic_mimetype_image + ) + ) + ChatMessageView(message = uiMessage) + } +} + +@Preview(showBackground = true, name = "Geolocation Message") +@Composable +private fun ChatMessageViewGeolocationPreview() { + PreviewContainer { + val uiMessage = createBaseMessage( + MessageTypeContent.Geolocation( + id = "geo:52.5200,13.4050", + name = "Berlin, Germany", + lat = 52.5200, + lon = 13.4050 + ) + ) + ChatMessageView(message = uiMessage) + } +} + +@Preview(showBackground = true, name = "Voice Message") +@Composable +private fun ChatMessageViewVoicePreview() { + PreviewContainer { + val uiMessage = createBaseMessage( + MessageTypeContent.Voice( + actorId = "john", + isPlaying = false, + wasPlayed = false, + isDownloading = false, + durationSeconds = 16, + playedSeconds = 4, + seekbarProgress = 25, + waveform = listOf(0.1f, 0.2f, 0.4f, 0.15f, 0.3f, 0.5f) + ) + ) + ChatMessageView(message = uiMessage) + } +} + +@Preview(showBackground = true, name = "Poll Message") +@Composable +private fun ChatMessageViewPollPreview() { + PreviewContainer { + val uiMessage = createBaseMessage( + MessageTypeContent.Poll( + pollId = "1", + pollName = "What's your favorite color?" + ) + ) + ChatMessageView(message = uiMessage) + } +} + +@Preview(showBackground = true, name = "Deck Message") +@Composable +private fun ChatMessageViewDeckPreview() { + PreviewContainer { + val uiMessage = createBaseMessage( + MessageTypeContent.Deck( + cardName = "Fix all bugs", + stackName = "Todo", + boardName = "Talk Android", + cardLink = "" + ) + ) + ChatMessageView(message = uiMessage) + } +} + +@Preview(showBackground = true, name = "Link Preview Message") +@Composable +private fun ChatMessageViewLinkPreviewPreview() { + PreviewContainer { + val uiMessage = createBaseMessage(MessageTypeContent.LinkPreview(url = "https://nextcloud.com/")) + ChatMessageView(message = uiMessage) + } +} + +@Composable +private fun PreviewContainer(content: @Composable () -> Unit) { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + content() + } +} + +private fun createBaseMessage(content: MessageTypeContent?): ChatMessageUi = + ChatMessageUi( + id = 1, + text = "Sample message text", + message = "Sample message text", + renderMarkdown = false, + actorDisplayName = "John Doe", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = true, + isDeleted = false, + avatarUrl = null, + statusIcon = MessageStatusIcon.SENT, + timestamp = System.currentTimeMillis() / 1000, + date = LocalDate.now(), + content = content, + reactions = PREVIEW_REACTIONS + ) + +private fun createBaseMessageWithoutCaption(content: MessageTypeContent?): ChatMessageUi = + ChatMessageUi( + id = 1, + text = "", + message = "", + renderMarkdown = false, + actorDisplayName = "John Doe", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = true, + isDeleted = false, + avatarUrl = null, + statusIcon = MessageStatusIcon.SENT, + timestamp = System.currentTimeMillis() / 1000, + date = LocalDate.now(), + content = content, + reactions = PREVIEW_REACTIONS + ) + +private fun createLongBaseMessage(content: MessageTypeContent?): ChatMessageUi = + ChatMessageUi( + id = 1, + text = "Sample message text that is very very very very very long", + message = "Sample message text that is very very very very very long", + renderMarkdown = false, + actorDisplayName = "John Doe", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = true, + isDeleted = false, + avatarUrl = null, + statusIcon = MessageStatusIcon.SENT, + timestamp = System.currentTimeMillis() / 1000, + date = LocalDate.now(), + content = content, + reactions = PREVIEW_REACTIONS + ) 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..baca71c34db --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -0,0 +1,437 @@ +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.Row +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.HorizontalDivider +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 + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GetNewChatView( + chatItems: List, + isOneToOneConversation: Boolean, + conversationThreadId: Long? = null, + onLoadMore: (() -> Unit?)?, + advanceLocalLastReadMessageIfNeeded: ((Int) -> Unit?)?, + updateRemoteLastReadMessageIfNeeded: (() -> Unit?)?, + onLongClick: ((Int) -> Unit?)?, + onFileClick: (Int) -> Unit, + onPollClick: (pollId: String, pollName: String) -> Unit = { _, _ -> }, + onVoicePlayPauseClick: (Int) -> Unit = {}, + onVoiceSeek: (messageId: Int, progress: Int) -> Unit = { _, _ -> }, + onVoiceSpeedClick: (Int) -> Unit = {}, + onReactionClick: (messageId: Int, emoji: String) -> Unit = { _, _ -> }, + onReactionLongClick: (messageId: Int) -> Unit = {}, + onOpenThreadClick: (messageId: Int) -> 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( + items = chatItems, + // key = { "" + it.stableKey() + it.hashCode() } // TODO remove hash + key = { it.stableKey() } + ) { chatItem -> + when (chatItem) { + is ChatViewModel.ChatItem.MessageItem -> { + ChatMessageView( + message = chatItem.uiMessage, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + onFileClick = onFileClick, + onLongClick = onLongClick, + onPollClick = onPollClick, + onVoicePlayPauseClick = onVoicePlayPauseClick, + onVoiceSeek = onVoiceSeek, + onVoiceSpeedClick = onVoiceSpeedClick, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onOpenThreadClick = onOpenThreadClick + ) + } + + 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) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider( + modifier = Modifier.weight(1f), + color = colorScheme.outlineVariant, + thickness = 1.dp + ) + + Text( + text = "Unread messages", + modifier = Modifier.padding(horizontal = 12.dp), + fontSize = 12.sp, + color = colorScheme.onSurfaceVariant + ) + + HorizontalDivider( + modifier = Modifier.weight(1f), + color = colorScheme.outlineVariant, + thickness = 1.dp + ) + } +} + +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..bb901345796 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt @@ -0,0 +1,61 @@ +/* + * 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, + isOneToOneConversation: Boolean = false, + conversationThreadId: Long? = null +) { + MessageScaffold( + uiMessage = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + forceTimeBelow = true, + 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..5ef37efe3ca --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/GeolocationMessage.kt @@ -0,0 +1,282 @@ +/* + * 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.content.Context +import android.content.Intent +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +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.chat.ui.model.MessageTypeContent +import java.time.LocalDate +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.image +import org.maplibre.compose.layers.SymbolLayer +import org.maplibre.compose.map.GestureOptions +import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.map.OrnamentOptions +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.style.BaseStyle +import org.maplibre.compose.style.rememberStyleState +import org.maplibre.compose.util.ClickResult +import org.maplibre.spatialk.geojson.Position + +@Composable +fun GeolocationMessage( + typeContent: MessageTypeContent.Geolocation, + message: ChatMessageUi, + isOneToOneConversation: Boolean = false, + conversationThreadId: Long? = null +) { + MessageScaffold( + uiMessage = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + forceTimeBelow = true, + includePadding = false, + content = { + GeolocationContent( + typeContent = typeContent, + message = message, + conversationThreadId = conversationThreadId + ) + } + ) +} + +@Composable +fun GeolocationContent( + typeContent: MessageTypeContent.Geolocation, + message: ChatMessageUi, + conversationThreadId: Long? = null +) { + val context = LocalContext.current + + Column { + val latitude = typeContent.lat + val longitude = typeContent.lon + Box { + OpenStreetMap(latitude, longitude) + // Transparent overlay on top of the map. By sitting above the MapView in Compose's + // z-order it receives touches first, preventing the AndroidView from calling + // requestDisallowInterceptTouchEvent(true) and blocking parent list scrolling. + // detectTapGestures does not consume drag events, so swipe-to-scroll propagates + // to the parent list naturally. + Box( + modifier = Modifier + .matchParentSize() + .pointerInput(typeContent.id) { + detectTapGestures { openGeoLink(context, typeContent.id) } + } + ) + Text( + text = stringResource(R.string.osm_map_view_attributation), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp), + style = MaterialTheme.typography.labelSmall, + color = Color.Black.copy(alpha = 0.7f) + ) + } + typeContent.name.let { name -> + if (name.isNotEmpty()) { + Text( + text = name, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} + +@Composable +fun OpenStreetMap(latitude: Double, longitude: Double) { + val cameraState = + rememberCameraState(CameraPosition(target = Position(longitude, latitude), zoom = 12.0)) + + val markerJson = """ + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [$longitude, $latitude] + }, + "properties": {} + } + ] + } + """.trimIndent() + + MaplibreMap( + modifier = Modifier.height(200.dp), + // TODO: load style from file incl. other tiles url + baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/liberty"), + cameraState = cameraState, + styleState = rememberStyleState(), + onMapClick = { _, _ -> ClickResult.Pass }, + options = MapOptions( + ornamentOptions = OrnamentOptions.AllDisabled, + gestureOptions = GestureOptions( + isScrollEnabled = false, + isZoomEnabled = false, + isRotateEnabled = false, + isTiltEnabled = false + ) + ) + ) { + val markerSource = rememberGeoJsonSource( + GeoJsonData.JsonString(markerJson) + ) + + SymbolLayer( + id = "marker-layer", + source = markerSource, + iconImage = image(painterResource(R.drawable.ic_baseline_location_on_red_24), drawAsSdf = true), + iconColor = const(Color.Red), + iconSize = const(1f), + iconAllowOverlap = const(true) + ) + } +} + +private fun openGeoLink(context: Context, geoLink: String) { + if (geoLink.isNotEmpty()) { + val geoLinkWithMarker = geoLink.replace("geo:", "geo:0,0?q=") + val browserIntent = Intent(Intent.ACTION_VIEW, geoLinkWithMarker.toUri()) + browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(browserIntent) + } +} + +@Preview(showBackground = true, name = "Geolocation Message Incoming") +@Preview( + showBackground = true, + name = "Geolocation Message Incoming Dark", + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +) +@Composable +private fun GeolocationMessagePreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + val typeContent = MessageTypeContent.Geolocation( + id = "geo:52.5200,13.4050", + name = "Berlin, Germany", + lat = 52.5200, + lon = 13.4050 + ) + val uiMessage = ChatMessageUi( + id = 1, + text = "Check out this location!", + message = "Check out this location!", + renderMarkdown = false, + actorDisplayName = "John Doe", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = true, + isDeleted = false, + avatarUrl = null, + statusIcon = MessageStatusIcon.SENT, + timestamp = System.currentTimeMillis() / 1000, + date = LocalDate.now(), + content = typeContent + ) + GeolocationMessage(typeContent = typeContent, message = uiMessage) + } +} + +@Preview(showBackground = true, name = "Geolocation Message Outgoing") +@Composable +private fun GeolocationMessageOutgoingPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + val typeContent = MessageTypeContent.Geolocation( + id = "geo:48.8566,2.3522", + name = "Paris, France", + lat = 48.8566, + lon = 2.3522 + ) + val uiMessage = ChatMessageUi( + id = 2, + text = "I am here!", + message = "I am here!", + renderMarkdown = false, + actorDisplayName = "Me", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = false, + isDeleted = false, + avatarUrl = null, + statusIcon = MessageStatusIcon.READ, + timestamp = System.currentTimeMillis() / 1000, + date = LocalDate.now(), + content = typeContent + ) + GeolocationMessage(typeContent = typeContent, message = uiMessage) + } +} + +@Preview(showBackground = true, name = "Geolocation Message No Name") +@Composable +private fun GeolocationMessageNoNamePreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + val typeContent = MessageTypeContent.Geolocation( + id = "geo:52.5200,13.4050", + name = "", + lat = 52.5200, + lon = 13.4050 + ) + val uiMessage = ChatMessageUi( + id = 3, + text = "Check this out", + message = "Check this out", + renderMarkdown = false, + actorDisplayName = "John Doe", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = true, + isDeleted = false, + avatarUrl = null, + statusIcon = MessageStatusIcon.SENT, + timestamp = System.currentTimeMillis() / 1000, + date = LocalDate.now(), + content = typeContent + ) + GeolocationMessage(typeContent = typeContent, message = uiMessage) + } +} 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..86bcbccec8c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt @@ -0,0 +1,127 @@ +/* + * 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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +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.models.json.opengraph.OpenGraphObject +import com.nextcloud.talk.ui.theme.LocalOpenGraphFetcher + +private val PREVIEW_IMAGE_HEIGHT = 120.dp +private const val HTTPS_PREFIX = "https://" + +@Composable +fun LinkMessage( + typeContent: MessageTypeContent.LinkPreview, + message: ChatMessageUi, + isOneToOneConversation: Boolean = false, + conversationThreadId: Long? = null +) { + val fetchOpenGraph = LocalOpenGraphFetcher.current + val openGraphObject by produceState(initialValue = null, key1 = typeContent.url) { + if (typeContent.url.isNotEmpty()) { + value = fetchOpenGraph(typeContent.url) + } + } + + MessageScaffold( + uiMessage = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + forceTimeBelow = true, + content = { + Column { + EnrichedText( + message, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + openGraphObject?.let { og -> + LinkPreviewCard(og) + } + } + } + ) +} + +@Composable +private fun LinkPreviewCard(og: OpenGraphObject) { + Surface( + shape = MaterialTheme.shapes.small, + tonalElevation = 1.dp, + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp, top = 4.dp) + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text( + text = og.name, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + og.description?.takeIf { it.isNotBlank() }?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + } + og.link?.takeIf { it.isNotBlank() }?.let { link -> + Text( + text = link.removePrefix(HTTPS_PREFIX), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + } + og.thumb?.takeIf { it.isNotBlank() }?.let { thumbUrl -> + val loadedImage = load( + imageUri = thumbUrl, + context = LocalContext.current, + errorPlaceholderImage = R.drawable.ic_mimetype_image + ) + AsyncImage( + model = loadedImage, + contentDescription = stringResource(R.string.nc_sent_an_image), + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .height(PREVIEW_IMAGE_HEIGHT) + .clip(MaterialTheme.shapes.small), + contentScale = ContentScale.Crop + ) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt new file mode 100644 index 00000000000..213794d0ad9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt @@ -0,0 +1,111 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +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 + +private const val FILE_PLACEHOLDER_MESSAGE = "{file}" + +private val MEDIA_RADIUS_BIG = 8.dp +private val MEDIA_RADIUS_SMALL = 2.dp + +@Composable +fun MediaMessage( + typeContent: MessageTypeContent.Media, + message: ChatMessageUi, + isOneToOneConversation: Boolean = false, + conversationThreadId: Long? = null, + onImageClick: (Int) -> Unit +) { + val captionText = message.message.takeUnless { it == FILE_PLACEHOLDER_MESSAGE } + val hasCaption = captionText != null + val mediaInset = 4.dp + val mediaShape = if (message.incoming) { + RoundedCornerShape( + topStart = MEDIA_RADIUS_SMALL, + topEnd = MEDIA_RADIUS_BIG, + bottomEnd = MEDIA_RADIUS_BIG, + bottomStart = MEDIA_RADIUS_BIG + ) + } else { + RoundedCornerShape( + topStart = MEDIA_RADIUS_BIG, + topEnd = MEDIA_RADIUS_SMALL, + bottomEnd = MEDIA_RADIUS_BIG, + bottomStart = MEDIA_RADIUS_BIG + ) + } + + MessageScaffold( + uiMessage = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + includePadding = false, + captionText = captionText, + forceTimeOverlay = !hasCaption, + content = { + Column { + val context = LocalContext.current + val resourceName = context.resources.getResourceEntryName(typeContent.drawableResourceId) + val showPlayButton = !typeContent.previewUrl.isNullOrEmpty() && + (resourceName.contains("video") || resourceName.contains("audio")) + + Box(modifier = Modifier.fillMaxWidth()) { + val loadedImage = load( + imageUri = typeContent.previewUrl, + context = context, + errorPlaceholderImage = typeContent.drawableResourceId + ) + + AsyncImage( + model = loadedImage, + contentDescription = "image preview", + modifier = Modifier + .fillMaxWidth() + .padding(mediaInset) + .clip(mediaShape) + .clickable { onImageClick(message.id) }, + contentScale = ContentScale.FillWidth + ) + + if (showPlayButton) { + Icon( + painter = painterResource(R.drawable.ic_baseline_play_arrow_voice_message_24), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .size(48.dp), + tint = Color.White + ) + } + } + } + } + ) +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MentionEnrichedText.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MentionEnrichedText.kt new file mode 100644 index 00000000000..d844c9cf388 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MentionEnrichedText.kt @@ -0,0 +1,391 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.TextUnit +import coil.compose.AsyncImage +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.contacts.loadImage +import com.nextcloud.talk.events.UserMentionClickEvent +import com.nextcloud.talk.ui.theme.LocalViewThemeUtils +import com.nextcloud.talk.utils.ApiUtils +import org.greenrobot.eventbus.EventBus + +private val messageTokenRegex = + Regex("""(\{[^{}]+\}|\*\*.*?\*\*|\*.*?\*|`.*?`|\[.*?]\(.*?\)|https?://\S+)""") + +private val mentionParameterTypes = setOf("user", "guest", "call", "user-group", "email", "circle") + +private val mentionAvatarSize = 20.dp +private val mentionIconSize = 20.dp + +private const val minChipLabelLength = 4 +private const val maxChipLabelLength = 22 +private const val chipBaseWidthEm = 2.45f +private const val chipCharWidthEm = 0.56f +private const val chipHeightEm = 1.75f +private const val multilineChipHeightEm = 1.95f + +private val chipVerticalPadding = 2.dp +private val multilineChipVerticalPadding = 3.dp + +private data class MentionChipModel( + val id: String, + val rawId: String, + val name: String, + val type: String, + val isFederated: Boolean, + val isSelfMention: Boolean, + val isClickableUserMention: Boolean, + val avatarUrl: String? +) + +private data class MentionRichText( + val annotated: AnnotatedString, + val inlineContent: Map +) + +@Composable +fun MentionEnrichedText( + message: ChatMessageUi, + modifier: Modifier = Modifier, + textStyle: TextStyle +) { + var isMultilineLayout by remember(message.id, message.message) { + mutableStateOf(message.message.contains("\n") || message.message.contains("\r")) + } + val linkColor = MaterialTheme.colorScheme.primary + val codeBackground = MaterialTheme.colorScheme.surfaceVariant + val richText = buildMentionRichText( + message = message, + linkColor = linkColor, + codeBackground = codeBackground, + textStyle = textStyle, + isMultilineLayout = isMultilineLayout + ) + val resolvedTextStyle = if (richText.inlineContent.isEmpty()) { + textStyle + } else { + textStyle.copy(lineHeight = TextUnit.Unspecified) + } + + Text( + modifier = modifier, + text = richText.annotated, + inlineContent = richText.inlineContent, + style = resolvedTextStyle, + onTextLayout = { textLayoutResult -> + val isCurrentlyMultiline = textLayoutResult.lineCount > 1 + if (isMultilineLayout != isCurrentlyMultiline) { + isMultilineLayout = isCurrentlyMultiline + } + } + ) +} + +private fun buildMentionRichText( + message: ChatMessageUi, + linkColor: Color, + codeBackground: Color, + textStyle: TextStyle, + isMultilineLayout: Boolean +): MentionRichText { + val inlineContent = linkedMapOf() + var mentionCounter = 0 + + val annotated = buildAnnotatedString { + var lastIndex = 0 + for (match in messageTokenRegex.findAll(message.message)) { + val range = match.range + if (lastIndex < range.first) { + append(message.message.substring(lastIndex, range.first)) + } + + val token = match.value + when { + token.startsWith("{") && token.endsWith("}") -> { + val mention = token.toMentionChipModel(message) + if (mention == null) { + appendFallbackParameter(token, message.messageParameters) + } else { + val inlineId = "mention-${message.id}-$mentionCounter" + mentionCounter += 1 + inlineContent[inlineId] = buildMentionInlineContent( + mention = mention, + textStyle = textStyle, + isMultilineLayout = isMultilineLayout + ) + appendInlineContent(inlineId, "@${mention.name}") + } + } + + token.startsWith("**") -> appendStyledToken(token.removeSurrounding("**"), SpanStyle(fontWeight = FontWeight.Bold)) + token.startsWith("*") -> appendStyledToken(token.removeSurrounding("*"), SpanStyle(fontStyle = FontStyle.Italic)) + token.startsWith("`") -> { + appendStyledToken( + token.removeSurrounding("`"), + SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground) + ) + } + + token.startsWith("[") -> { + val textPart = token.substringAfter("[").substringBefore("]") + val url = token.substringAfter("(").substringBefore(")") + appendLinkedToken(textPart, url, linkColor) + } + + token.startsWith("http") -> appendLinkedToken(token, token, linkColor) + } + + lastIndex = range.last + 1 + } + + if (lastIndex < message.message.length) { + append(message.message.substring(lastIndex)) + } + } + + return MentionRichText(annotated = annotated, inlineContent = inlineContent) +} + +private fun AnnotatedString.Builder.appendStyledToken(text: String, style: SpanStyle) { + val start = length + append(text) + addStyle(style, start, length) +} + +private fun AnnotatedString.Builder.appendLinkedToken(text: String, url: String, linkColor: Color) { + val start = length + append(text) + addStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), start, length) + addLink(LinkAnnotation.Url(url), start, length) +} + +private fun AnnotatedString.Builder.appendFallbackParameter( + token: String, + messageParameters: Map> +) { + val key = token.removePrefix("{").removeSuffix("}") + val replacementText = messageParameters[key]?.get("name") + if (replacementText == null) { + append(token) + } else { + appendStyledToken(replacementText, SpanStyle(fontWeight = FontWeight.Bold)) + } +} + +private fun buildMentionInlineContent( + mention: MentionChipModel, + textStyle: TextStyle, + isMultilineLayout: Boolean +): InlineTextContent { + val width = estimateMentionChipWidthInEm(mention.name) + val placeholderHeight = if (isMultilineLayout) multilineChipHeightEm else chipHeightEm + return InlineTextContent( + placeholder = Placeholder( + width = width.em, + height = placeholderHeight.em, + placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom + ) + ) { _ -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomStart + ) { + MentionChip( + mention = mention, + textStyle = textStyle, + isMultilineLayout = isMultilineLayout + ) + } + } +} + +private fun estimateMentionChipWidthInEm(label: String): Float { + val clampedLength = label.length.coerceIn(minChipLabelLength, maxChipLabelLength) + return chipBaseWidthEm + (clampedLength * chipCharWidthEm) +} + +@Composable +private fun MentionChip( + mention: MentionChipModel, + textStyle: TextStyle, + isMultilineLayout: Boolean +) { + val context = LocalContext.current + val viewThemeUtils = LocalViewThemeUtils.current + val density = LocalDensity.current + val chipCornerRadius = dimensionResource(R.dimen.standard_padding) + val chipTextSize = with(density) { + if (textStyle.fontSize.isSpecified) { + textStyle.fontSize + } else { + dimensionResource(R.dimen.chat_text_size).value.sp + } + } + val backgroundColor = if (mention.isSelfMention) { + viewThemeUtils.getColorScheme(context).primary + } else { + Color.White.copy(alpha = 0.87f) + } + val textColor = if (mention.isSelfMention) { + colorResource(R.color.textColorOnPrimaryBackground) + } else { + colorResource(R.color.high_emphasis_text) + } + val fallbackIcon = resolveMentionFallbackIcon(mention) + val verticalPadding = if (isMultilineLayout) multilineChipVerticalPadding else chipVerticalPadding + + Row( + modifier = Modifier + .background(backgroundColor, RoundedCornerShape(chipCornerRadius)) + .clickable(enabled = mention.isClickableUserMention) { + EventBus.getDefault().post(UserMentionClickEvent(mention.id)) + } + .padding(start = 4.dp, top = verticalPadding, end = 4.dp, bottom = verticalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (mention.avatarUrl != null) { + val loadedImage = loadImage(mention.avatarUrl, context, fallbackIcon) + AsyncImage(model = loadedImage, contentDescription = null, modifier = Modifier.size(mentionAvatarSize)) + } else { + Icon( + painter = painterResource(fallbackIcon), + contentDescription = null, + modifier = Modifier.size(mentionIconSize), + tint = Color.Unspecified + ) + } + + Text( + text = mention.name, + color = textColor, + maxLines = 1, + style = textStyle.copy( + color = textColor, + fontSize = chipTextSize, + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal, + fontFamily = FontFamily.Default + ) + ) + } +} + +private fun String.toMentionChipModel(message: ChatMessageUi): MentionChipModel? { + val parameter = message.messageParameters[removePrefix("{").removeSuffix("}")] ?: return null + val type = parameter["type"] ?: return null + if (!mentionParameterTypes.contains(type)) { + return null + } + + val rawId = parameter["id"].orEmpty() + val name = parameter["name"].orEmpty() + val server = parameter["server"] + val isFederated = !server.isNullOrEmpty() + val mentionId = if (isFederated) "$rawId@$server" else rawId + val isSelfMention = rawId == message.activeUserId + val avatarUrl = resolveMentionAvatarUrl(message, rawId, name, type, mentionId, isFederated) + + return MentionChipModel( + id = mentionId, + rawId = rawId, + name = name, + type = type, + isFederated = isFederated, + isSelfMention = isSelfMention, + isClickableUserMention = type == "user" && !isSelfMention && !isFederated, + avatarUrl = avatarUrl + ) +} + +private fun resolveMentionAvatarUrl( + message: ChatMessageUi, + rawId: String, + mentionName: String, + mentionType: String, + mentionId: String, + isFederated: Boolean +): String? { + val baseUrl = message.activeUserBaseUrl ?: return null + + return when { + isFederated && !message.roomToken.isNullOrEmpty() -> { + ApiUtils.getUrlForFederatedAvatar( + baseUrl = baseUrl, + token = message.roomToken, + cloudId = mentionId, + darkTheme = 0, + requestBigSize = false + ) + } + + mentionType == "guest" || mentionType == "email" -> { + ApiUtils.getUrlForGuestAvatar(baseUrl = baseUrl, name = mentionName, requestBigSize = true) + } + + mentionType == "call" || mentionType == "user-group" || mentionType == "circle" -> null + rawId.isNotEmpty() -> ApiUtils.getUrlForAvatar(baseUrl, rawId, false, false) + else -> null + } +} + +private fun resolveMentionFallbackIcon(mention: MentionChipModel): Int { + return when { + mention.type == "call" && mention.name.startsWith("+") -> R.drawable.icon_circular_phone + mention.type == "call" -> R.drawable.ic_circular_group_mentions + mention.type == "user-group" -> R.drawable.ic_circular_group_mentions + mention.type == "circle" -> R.drawable.icon_circular_team + mention.isSelfMention -> R.drawable.mention_chip + else -> R.drawable.accent_circle + } +} 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..d1880dae94d --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/PollMessage.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.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.Alignment +import androidx.compose.ui.Modifier +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, + isOneToOneConversation: Boolean = false, + conversationThreadId: Long? = null, + onPollClick: (pollId: String, pollName: String) -> Unit = { _, _ -> } +) { + MessageScaffold( + uiMessage = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + forceTimeBelow = true, + content = { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + 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, + modifier = Modifier.padding(start = 4.dp) + ) + } + + TextButtonNoStyling(stringResource(R.string.message_poll_tap_to_open)) { + onPollClick(typeContent.pollId, typeContent.pollName) + } + } + } + ) +} + +@Composable +private fun TextButtonNoStyling(text: String, onClick: () -> Unit) { + TextButton(onClick = onClick) { + Text( + text, + fontSize = AUTHOR_TEXT_SIZE.sp + ) + } +} 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..4355ed87323 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt @@ -0,0 +1,43 @@ +package com.nextcloud.talk.ui.chat + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.utils.DateUtils + +private const val AUTHOR_TEXT_SIZE = 12 +private const val TIME_TEXT_SIZE = 12 + +@Composable +fun SystemMessage(message: ChatMessageUi) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) + Box(modifier = Modifier.fillMaxWidth()) { + Text( + message.text, + fontSize = AUTHOR_TEXT_SIZE.sp, + modifier = Modifier + .padding(8.dp) + .align(Alignment.Center) + ) + Text( + timeString, + fontSize = TIME_TEXT_SIZE.sp, + textAlign = TextAlign.End, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 8.dp) + ) + } + } +} 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..fdc8151aec6 --- /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.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.nextcloud.talk.chat.ui.model.ChatMessageUi + +@Composable +fun TextMessage(uiMessage: ChatMessageUi, isOneToOneConversation: Boolean = false, conversationThreadId: Long? = null) { + MessageScaffold( + uiMessage = uiMessage, + conversationThreadId = conversationThreadId, + isOneToOneConversation = isOneToOneConversation, + content = { + EnrichedText( + uiMessage, + Modifier.padding(start = 0.dp) + ) + } + ) +} 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..225c70e3acf --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt @@ -0,0 +1,146 @@ +/* + * 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.text.format.DateUtils +import android.widget.SeekBar +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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 + +private const val SEEKBAR_MAX = 100 + +@Composable +fun VoiceMessage( + typeContent: MessageTypeContent.Voice, + message: ChatMessageUi, + isOneToOneConversation: Boolean = false, + conversationThreadId: Long? = null, + onPlayPauseClick: (Int) -> Unit = {}, + onSeek: (messageId: Int, progress: Int) -> Unit = { _, _ -> }, + onSpeedClick: (messageId: Int) -> Unit = {} +) { + MessageScaffold( + uiMessage = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + forceTimeBelow = true, + content = { + val inversePrimary = colorScheme.inversePrimary.toArgb() + val onPrimaryContainer = colorScheme.onPrimaryContainer.toArgb() + val remainingSeconds = (typeContent.durationSeconds - typeContent.playedSeconds).coerceAtLeast(0) + val waveformData = typeContent.waveform.toFloatArray() + val lastWaveformData = remember { mutableListOf() } + + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + if (typeContent.isDownloading) { + CircularProgressIndicator(modifier = Modifier.size(48.dp), strokeWidth = 2.dp) + } else { + IconButton( + onClick = { onPlayPauseClick(message.id) }, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = if (typeContent.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (typeContent.isPlaying) "Pause" else "Play", + modifier = Modifier.size(40.dp) + ) + } + } + + AndroidView( + factory = { ctx -> + WaveformSeekBar(ctx).apply { + max = SEEKBAR_MAX + setWaveData(waveformData) + setColors( + inversePrimary, + onPrimaryContainer + ) + setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged( + seekBar: SeekBar?, + progress: Int, + fromUser: Boolean + ) { + if (fromUser) { + onSeek(message.id, progress) + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + + override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit + }) + } + }, + update = { seekBar -> + seekBar.max = SEEKBAR_MAX + val waveformChanged = typeContent.waveform != lastWaveformData + if (waveformChanged) { + lastWaveformData.clear() + lastWaveformData.addAll(typeContent.waveform) + seekBar.setWaveData(waveformData) + seekBar.requestLayout() + } + seekBar.setColors(inversePrimary, onPrimaryContainer) + seekBar.progress = typeContent.seekbarProgress + seekBar.isEnabled = !typeContent.isDownloading + seekBar.invalidate() + }, + modifier = Modifier + .weight(1f) + .height(56.dp) + ) + + TextButton( + onClick = { onSpeedClick(message.id) }, + modifier = Modifier.padding(start = 4.dp) + ) { + Text( + text = typeContent.playbackSpeed.label, + color = colorScheme.onPrimaryContainer + ) + } + } + + Text( + text = DateUtils.formatElapsedTime(remainingSeconds.toLong()), + color = colorScheme.onPrimaryContainer, + modifier = Modifier.padding(start = 4.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 feaad705bb0..93694c42ba7 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 @@ -79,7 +79,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 { @@ -91,9 +91,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/dialog/MessageActionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt index 889b79d49fd..248350237b6 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt @@ -32,8 +32,6 @@ import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogMessageActionsBinding 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.conversations.ConversationEnums import com.nextcloud.talk.repositories.reactions.ReactionsRepository @@ -52,10 +50,6 @@ import com.vanniktech.emoji.installDisableKeyboardInput import com.vanniktech.emoji.installForceSingleEmoji import com.vanniktech.emoji.recent.RecentEmojiManager import com.vanniktech.emoji.search.SearchEmojiManager -import io.reactivex.Observer -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.launch import java.util.Date import javax.inject.Inject @@ -611,77 +605,33 @@ class MessageActionsDialog( message.id ) - if (message.reactionsSelf?.contains(emoji) == true) { - reactionsRepository.deleteReaction( - credentials, - user.id!!, - url, - currentConversation.token, - message, - emoji - ) - .subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(ReactionDeletedObserver()) - } else { - reactionsRepository.addReaction( - credentials, - user.id!!, - url, - currentConversation.token, - message, - emoji - ) - .subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(ReactionAddedObserver()) - } - } - - inner class ReactionAddedObserver : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(reactionAddedModel: ReactionAddedModel) { - if (reactionAddedModel.success) { - chatActivity.updateUiToAddReaction( - reactionAddedModel.chatMessage, - reactionAddedModel.emoji - ) - } - } - - override fun onError(e: Throwable) { - Log.e(TAG, "failure in ReactionAddedObserver", e) - } - - override fun onComplete() { - dismiss() - } - } - - inner class ReactionDeletedObserver : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(reactionDeletedModel: ReactionDeletedModel) { - if (reactionDeletedModel.success) { - chatActivity.updateUiToDeleteReaction( - reactionDeletedModel.chatMessage, - reactionDeletedModel.emoji - ) + chatActivity.lifecycleScope.launch { + try { + if (message.reactionsSelf?.contains(emoji) == true) { + reactionsRepository.deleteReaction( + credentials, + user.id!!, + url, + currentConversation.token, + message, + emoji + ) + } else { + reactionsRepository.addReaction( + credentials, + user.id!!, + url, + currentConversation.token, + message, + emoji + ) + } + } catch (e: Exception) { + Log.e(TAG, "clickOnEmoji error", e) + } finally { + dismiss() } } - - override fun onError(e: Throwable) { - Log.e(TAG, "failure in ReactionDeletedObserver", e) - } - - override fun onComplete() { - dismiss() - } } companion object { diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt index 803346532ca..f933aa627ec 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/ShowReactionsDialog.kt @@ -23,21 +23,16 @@ import com.nextcloud.talk.R import com.nextcloud.talk.adapters.ReactionItem import com.nextcloud.talk.adapters.ReactionItemClickListener import com.nextcloud.talk.adapters.ReactionsAdapter -import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogMessageReactionsBinding import com.nextcloud.talk.databinding.ItemReactionsTabBinding -import com.nextcloud.talk.models.json.generic.GenericOverall -import com.nextcloud.talk.models.json.reactions.ReactionsOverall import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.ApiUtils -import io.reactivex.Observer -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import java.util.Collections import java.util.Locale import javax.inject.Inject @@ -49,7 +44,7 @@ class ShowReactionsDialog( private val chatMessage: ChatMessage, private val user: User?, private val hasReactPermission: Boolean, - private val ncApi: NcApi + private val ncApiCoroutines: NcApiCoroutines ) : BottomSheetDialog(activity), ReactionItemClickListener { @@ -140,90 +135,65 @@ class ShowReactionsDialog( val credentials = ApiUtils.getCredentials(user?.username, user?.token) - ncApi.getReactions( - credentials, - ApiUtils.getUrlForMessageReaction( - user?.baseUrl!!, - roomToken, - chatMessage.id - ), - emoji - ) - ?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(reactionsOverall: ReactionsOverall) { - val reactionVoters: ArrayList = ArrayList() - if (reactionsOverall.ocs?.data != null) { - val map = reactionsOverall.ocs?.data - for (key in map!!.keys) { - for (reactionVoter in reactionsOverall.ocs?.data!![key]!!) { - reactionVoters.add(ReactionItem(reactionVoter, key)) - } + (activity as? androidx.lifecycle.LifecycleOwner)?.lifecycleScope?.launch { + try { + val reactionsOverall = ncApiCoroutines.getReactions( + credentials, + ApiUtils.getUrlForMessageReaction( + user?.baseUrl!!, + roomToken, + chatMessage.id + ), + emoji + ) + val reactionVoters: ArrayList = ArrayList() + if (reactionsOverall.ocs?.data != null) { + val map = reactionsOverall.ocs?.data + for (key in map!!.keys) { + for (reactionVoter in reactionsOverall.ocs?.data!![key]!!) { + reactionVoters.add(ReactionItem(reactionVoter, key)) } - - Collections.sort(reactionVoters, ReactionComparator(user.userId)) - - adapter?.list?.addAll(reactionVoters) - adapter?.notifyDataSetChanged() - } else { - Log.e(TAG, "no voters for this reaction") } + Collections.sort(reactionVoters, ReactionComparator(user?.userId)) + adapter?.list?.addAll(reactionVoters) + adapter?.notifyDataSetChanged() + } else { + Log.e(TAG, "no voters for this reaction") } - - override fun onError(e: Throwable) { - Log.e(TAG, "failed to retrieve list of reaction voters") - } - - override fun onComplete() { - // unused atm - } - }) + } catch (e: Exception) { + Log.e(TAG, "failed to retrieve list of reaction voters") + } + } } override fun onClick(reactionItem: ReactionItem) { if (hasReactPermission && reactionItem.reactionVoter.actorId?.equals(user?.userId) == true) { deleteReaction(chatMessage, reactionItem.reaction!!) - adapter?.list?.remove(reactionItem) dismiss() } } private fun deleteReaction(message: ChatMessage, emoji: String) { val credentials = ApiUtils.getCredentials(user?.username, user?.token) - ncApi.deleteReaction( - credentials, - ApiUtils.getUrlForMessageReaction( - user?.baseUrl!!, - roomToken, - message.id - ), - emoji - ) - ?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(genericOverall: GenericOverall) { - Log.d(TAG, "deleted reaction: $emoji") - (activity as ChatActivity).updateUiToDeleteReaction(message, emoji) - } - - override fun onError(e: Throwable) { - Log.e(TAG, "error while deleting reaction: $emoji") - } - override fun onComplete() { - dismiss() - } - }) + (activity as? androidx.lifecycle.LifecycleOwner)?.lifecycleScope?.launch { + try { + ncApiCoroutines.deleteReaction( + credentials, + ApiUtils.getUrlForMessageReaction( + user?.baseUrl!!, + roomToken, + message.id + ), + emoji + ) + Log.d(TAG, "deleted reaction: $emoji") + } catch (e: Exception) { + Log.e(TAG, "error while deleting reaction: $emoji") + } finally { + dismiss() + } + } } companion object { 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..b870398585a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/CompositionLocals.kt @@ -0,0 +1,25 @@ +/* + * 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.models.json.opengraph.OpenGraphObject +import com.nextcloud.talk.utils.message.MessageUtils + +val LocalViewThemeUtils = staticCompositionLocalOf { + error("ViewThemeUtils not provided") +} + +val LocalMessageUtils = staticCompositionLocalOf { + error("MessageUtils not provided") +} + +/** Fetches open graph data for a URL. Returns null when not available or in previews. */ +val LocalOpenGraphFetcher = staticCompositionLocalOf OpenGraphObject?> { + { null } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt index 497e388fbe1..1b17b1335db 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt @@ -24,7 +24,6 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import com.google.android.material.snackbar.Snackbar import com.nextcloud.talk.R -import com.nextcloud.talk.adapters.messages.PreviewMessageViewHolder import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.fullscreenfile.FullScreenImageActivity @@ -61,34 +60,28 @@ import java.util.concurrent.ExecutionException */ class FileViewerUtils(private val context: Context, private val user: User) { - fun openFile(message: ChatMessage, progressUi: ProgressUi) { - val fileName = message.selectedIndividualHashMap!![PreviewMessageViewHolder.KEY_NAME]!! - val mimetype = message.selectedIndividualHashMap!![PreviewMessageViewHolder.KEY_MIMETYPE]!! - val link = message.selectedIndividualHashMap!!["link"]!! + fun openFile(message: ChatMessage) { + val fileName = message.fileParameters.name + val mimetype = message.fileParameters.mimetype + val link = message.fileParameters.link - val fileId = message.selectedIndividualHashMap!![PreviewMessageViewHolder.KEY_ID]!! - val path = message.selectedIndividualHashMap!![PreviewMessageViewHolder.KEY_PATH]!! + val fileId = message.fileParameters.id + val path = message.fileParameters.path - var size = message.selectedIndividualHashMap!!["size"] - if (size == null) { - size = "-1" - } - val fileSize = size.toLong() + val fileSize = message.fileParameters.size openFile( FileInfo(fileId, fileName, fileSize, path, link, mimetype), - progressUi, message.openWhenDownloaded ) } - fun openFile(fileInfo: FileInfo, progressUi: ProgressUi, openWhenDownloaded: Boolean) { + fun openFile(fileInfo: FileInfo, openWhenDownloaded: Boolean) { if (isSupportedForInternalViewer(fileInfo.mimetype) || canBeHandledByExternalApp(fileInfo.mimetype, fileInfo.fileName) ) { openOrDownloadFile( fileInfo, - progressUi, openWhenDownloaded ) } else if (!fileInfo.link.isNullOrEmpty()) { @@ -112,14 +105,13 @@ class FileViewerUtils(private val context: Context, private val user: User) { return intent.resolveActivity(context.packageManager) != null } - private fun openOrDownloadFile(fileInfo: FileInfo, progressUi: ProgressUi, openWhenDownloaded: Boolean) { + private fun openOrDownloadFile(fileInfo: FileInfo, openWhenDownloaded: Boolean) { val file = File(context.cacheDir, fileInfo.fileName) if (file.exists()) { openFileByMimetype(fileInfo.fileName, fileInfo.mimetype, fileInfo.link, fileInfo.fileId) } else { downloadFileToCache( fileInfo, - progressUi, openWhenDownloaded ) } @@ -248,7 +240,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { } @SuppressLint("LongLogTag") - private fun downloadFileToCache(fileInfo: FileInfo, progressUi: ProgressUi, openWhenDownloaded: Boolean) { + private fun downloadFileToCache(fileInfo: FileInfo, openWhenDownloaded: Boolean) { // check if download worker is already running val workers = WorkManager.getInstance(context).getWorkInfosByTag(fileInfo.fileId) try { @@ -288,14 +280,12 @@ class FileViewerUtils(private val context: Context, private val user: User) { .addTag(fileInfo.fileId) .build() WorkManager.getInstance().enqueue(downloadWorker) - progressUi.progressBar?.visibility = View.VISIBLE WorkManager.getInstance(context).getWorkInfoByIdLiveData(downloadWorker.id) .observeForever { workInfo: WorkInfo? -> updateViewsByProgress( fileInfo.fileName, fileInfo.mimetype, workInfo!!, - progressUi, openWhenDownloaded, fileInfo.link, fileInfo.fileId @@ -307,7 +297,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { fileName: String, mimetype: String?, workInfo: WorkInfo, - progressUi: ProgressUi, + // progressUi: ProgressUi, openWhenDownloaded: Boolean, link: String? = null, fileId: String = "" @@ -316,15 +306,15 @@ class FileViewerUtils(private val context: Context, private val user: User) { WorkInfo.State.RUNNING -> { val progress = workInfo.progress.getInt(DownloadFileToCacheWorker.PROGRESS, -1) if (progress > -1) { - progressUi.messageText?.text = String.format( - context.resources.getString(R.string.filename_progress), - fileName, - progress - ) + // progressUi.messageText?.text = String.format( + // context.resources.getString(R.string.filename_progress), + // fileName, + // progress + // ) } } WorkInfo.State.SUCCEEDED -> { - if (progressUi.previewImage.isShown && openWhenDownloaded) { + if (openWhenDownloaded) { openFileByMimetype(fileName, mimetype, link, fileId) } else { Log.d( @@ -334,12 +324,12 @@ class FileViewerUtils(private val context: Context, private val user: User) { "openWhenDownloaded is false" ) } - progressUi.messageText?.text = fileName - progressUi.progressBar?.visibility = View.GONE + // progressUi.messageText?.text = fileName + // progressUi.progressBar?.visibility = View.GONE } WorkInfo.State.FAILED -> { - progressUi.messageText?.text = fileName - progressUi.progressBar?.visibility = View.GONE + // progressUi.messageText?.text = fileName + // progressUi.progressBar?.visibility = View.GONE } else -> { } @@ -369,7 +359,6 @@ class FileViewerUtils(private val context: Context, private val user: User) { fileName, mimeType, info!!, - progressUi, openWhenDownloaded ) } 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 8b097be2381..2dd4fe4f5e2 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 @@ -49,6 +49,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 @@ -165,7 +166,7 @@ class ComposePreviewUtils private constructor(context: Context) { ) val reactionsRepository: ReactionsRepository - get() = ReactionsRepositoryImpl(ncApi, chatMessagesDao) + get() = ReactionsRepositoryImpl(ncApiCoroutines, chatMessagesDao) val mediaRecorderManager: MediaRecorderManager get() = MediaRecorderManager() @@ -173,16 +174,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..7401d336dd9 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 getMessagesEqualOrNewerThan( + internalConversationId: String, + threadId: Long?, + oldestMessageId: Long + ): Flow> = flowOf() + + override fun getMessagesForConversation( + internalConversationId: String, + threadId: Long? + ): Flow> = flowOf() override fun getTempMessagesForConversation(internalConversationId: String): Flow> = flowOf() @@ -43,7 +52,7 @@ class DummyChatMessagesDaoImpl : ChatMessagesDao { ): Flow = flowOf() override suspend fun upsertChatMessages(chatMessages: List) { - /* */ + TODO("Not yet implemented") } override suspend fun upsertChatMessage(chatMessage: ChatMessageEntity) { @@ -58,9 +67,18 @@ class DummyChatMessagesDaoImpl : ChatMessagesDao { override suspend fun getChatMessageEntity(internalConversationId: String, messageId: Long): ChatMessageEntity? = null - override fun deleteChatMessages(internalIds: List) { - /* */ - } + 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) { + // /* */ + // } override fun deleteTempChatMessages(internalConversationId: String, referenceIds: List) { /* */ @@ -259,4 +277,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 94013003eaa..4481c724f02 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" /> + +