From e8bd9eec1765f15ac0dbfaff88ad300b0d40c947 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 10 Dec 2025 17:19:17 +0100 Subject: [PATCH 01/19] new chat architecture incl chat relay Signed-off-by: Marcel Hibbe --- app/build.gradle | 2 +- .../nextcloud/talk/activities/BaseActivity.kt | 4 + .../nextcloud/talk/activities/CallActivity.kt | 11 +- .../talk/activities/ParticipantHandler.kt | 2 +- .../messages/IncomingTextMessageViewHolder.kt | 7 +- .../OutcomingTextMessageViewHolder.kt | 7 +- .../messages/PreviewMessageViewHolder.kt | 55 +- .../java/com/nextcloud/talk/api/NcApi.java | 12 - .../com/nextcloud/talk/api/NcApiCoroutines.kt | 8 + .../com/nextcloud/talk/chat/ChatActivity.kt | 640 +++++---- .../talk/chat/UnreadMessagesPopup.kt | 57 + .../talk/chat/data/ChatMessageRepository.kt | 34 +- .../talk/chat/data/model/ChatMessage.kt | 43 +- .../data/network/ChatNetworkDataSource.kt | 7 +- .../network/OfflineFirstChatRepository.kt | 805 +++++------ .../chat/data/network/RetrofitChatNetwork.kt | 6 +- .../talk/chat/domain/ChatPullResult.kt | 18 + .../talk/chat/viewmodels/ChatViewModel.kt | 294 ++++- .../talk/contextchat/ContextChatView.kt | 32 +- .../talk/contextchat/ContextChatViewModel.kt | 4 - .../ConversationsListActivity.kt | 113 +- .../talk/dagger/modules/ViewModelModule.kt | 25 +- .../talk/data/database/dao/ChatBlocksDao.kt | 12 + .../talk/data/database/dao/ChatMessagesDao.kt | 50 +- .../talk/data/source/local/TalkDatabase.kt | 2 +- .../activities/SharedItemsActivity.kt | 35 +- .../signaling/ConversationMessageNotifier.kt | 8 + ...eiver.java => SignalingMessageReceiver.kt} | 523 ++++---- .../nextcloud/talk/ui/ComposeChatAdapter.kt | 1176 ++--------------- .../com/nextcloud/talk/ui/PinnedMessage.kt | 17 +- .../nextcloud/talk/ui/chat/ChatUiMessage.kt | 14 + .../com/nextcloud/talk/ui/chat/ChatView.kt | 376 ++++++ .../com/nextcloud/talk/ui/chat/DeckMessage.kt | 63 + .../talk/ui/chat/GeolocationMessage.kt | 88 ++ .../nextcloud/talk/ui/chat/ImageMessage.kt | 108 ++ .../com/nextcloud/talk/ui/chat/LinkMessage.kt | 70 + .../com/nextcloud/talk/ui/chat/Message.kt | 366 +++++ .../com/nextcloud/talk/ui/chat/PollMessage.kt | 69 + .../com/nextcloud/talk/ui/chat/Shimmer.kt | 118 ++ .../nextcloud/talk/ui/chat/SystemMessage.kt | 72 + .../com/nextcloud/talk/ui/chat/TextMessage.kt | 26 + .../nextcloud/talk/ui/chat/VoiceMessage.kt | 67 + .../talk/ui/dialog/DateTimeCompose.kt | 5 +- .../talk/ui/theme/CompositionLocals.kt | 19 + .../talk/utils/preview/ComposePreviewUtils.kt | 23 +- .../utils/preview/ComposePreviewUtilsDaos.kt | 19 +- .../talk/webrtc/WebSocketInstance.kt | 23 +- app/src/main/res/layout/activity_chat.xml | 97 +- 48 files changed, 3407 insertions(+), 2225 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/domain/ChatPullResult.kt rename app/src/main/java/com/nextcloud/talk/signaling/{SignalingMessageReceiver.java => SignalingMessageReceiver.kt} (64%) create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/ChatUiMessage.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/ImageMessage.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/Message.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/build.gradle b/app/build.gradle index 4426dbeeb86..65f4b9c605f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -185,7 +185,7 @@ configurations.configureEach { } dependencies { - + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.10.0' kapt "org.jetbrains.kotlin:kotlin-metadata-jvm:${kotlinVersion}" implementation "androidx.room:room-testing-android:${roomVersion}" implementation 'androidx.compose.foundation:foundation-layout:1.10.3' diff --git a/app/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/messages/IncomingTextMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt index b1722de2d42..99442632ff7 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt @@ -10,6 +10,7 @@ package com.nextcloud.talk.adapters.messages import android.content.Context +import android.text.SpannableStringBuilder import android.util.Log import android.util.TypedValue import android.view.View @@ -150,10 +151,10 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) : binding.messageAuthor.visibility = View.GONE } binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) - binding.messageText.text = processedMessageText + // binding.messageText.text = processedMessageText // just for debugging: - // binding.messageText.text = - // SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")") + binding.messageText.text = + SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")") } else { binding.checkboxContainer.visibility = View.VISIBLE binding.messageText.visibility = View.GONE diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt index 1f7afdc4496..afec599aaf5 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt @@ -10,6 +10,7 @@ package com.nextcloud.talk.adapters.messages import android.content.Context +import android.text.SpannableStringBuilder import android.util.Log import android.util.TypedValue import android.view.View @@ -163,10 +164,10 @@ class OutcomingTextMessageViewHolder(itemView: View) : binding.messageTime.layoutParams = layoutParams viewThemeUtils.platform.colorTextView(binding.messageText, ColorRole.ON_SURFACE_VARIANT) - binding.messageText.text = processedMessageText + // binding.messageText.text = processedMessageText // just for debugging: - // binding.messageText.text = - // SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")") + binding.messageText.text = + SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")") } else { binding.messageText.visibility = View.GONE binding.checkboxContainer.visibility = View.VISIBLE diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt index fda68f9f824..14e52008ab6 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/PreviewMessageViewHolder.kt @@ -112,36 +112,37 @@ abstract class PreviewMessageViewHolder(itemView: View?, payload: Any?) : clickView = image messageText.visibility = View.VISIBLE if (message.getCalculateMessageType() === ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE) { - val chatActivity = commonMessageInterface as ChatActivity - fileViewerUtils = FileViewerUtils(chatActivity, message.activeUser!!) - val fileName = message.selectedIndividualHashMap!![KEY_NAME] - - messageText.text = fileName - - if (message.activeUser != null && - message.activeUser!!.username != null && - message.activeUser!!.baseUrl != null - ) { - clickView!!.setOnClickListener { v: View? -> - fileViewerUtils!!.openFile( - message, - ProgressUi(progressBar, messageText, image) - ) - } - clickView!!.setOnLongClickListener { - previewMessageInterface!!.onPreviewMessageLongClick(message) - true + message.activeUser?.let { + val chatActivity = commonMessageInterface as ChatActivity + fileViewerUtils = FileViewerUtils(chatActivity, it) + val fileName = message.selectedIndividualHashMap!![KEY_NAME] + messageText.text = fileName + if ( + it.username != null && + it.baseUrl != null + ) { + clickView!!.setOnClickListener { v: View? -> + fileViewerUtils!!.openFile( + message, + ProgressUi(progressBar, messageText, image) + ) + } + clickView!!.setOnLongClickListener { + previewMessageInterface!!.onPreviewMessageLongClick(message) + true + } } - } else { + + fileViewerUtils?.resumeToUpdateViewsByProgress( + message.selectedIndividualHashMap!![KEY_NAME]!!, + message.selectedIndividualHashMap!![KEY_ID]!!, + message.selectedIndividualHashMap!![KEY_MIMETYPE], + message.openWhenDownloaded, + ProgressUi(progressBar, messageText, image) + ) + } ?: { Log.e(TAG, "failed to set click listener because activeUser, username or baseUrl were null") } - fileViewerUtils!!.resumeToUpdateViewsByProgress( - message.selectedIndividualHashMap!![KEY_NAME]!!, - message.selectedIndividualHashMap!![KEY_ID]!!, - message.selectedIndividualHashMap!![KEY_MIMETYPE], - message.openWhenDownloaded, - ProgressUi(progressBar, messageText, image) - ) } else if (message.getCalculateMessageType() === ChatMessage.MessageType.SINGLE_LINK_GIPHY_MESSAGE) { messageText.text = "GIPHY" DisplayUtils.setClickableString("GIPHY", "https://giphy.com", messageText) diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApi.java b/app/src/main/java/com/nextcloud/talk/api/NcApi.java index 972c0b1f738..b8d66819616 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -325,18 +325,6 @@ Observable> setPassword2(@Header("Authorization") Strin Observable getRoomCapabilities(@Header("Authorization") String authorization, @Url String url); - /* - QueryMap items are as follows: - - "lookIntoFuture": int (0 or 1), - - "limit" : int, range 100-200, - - "timeout": used with look into future, 30 default, 60 at most - - "lastKnownMessageId", int, use one from X-Chat-Last-Given - */ - @GET - Observable> pullChatMessages(@Header("Authorization") String authorization, - @Url String url, - @QueryMap Map fields); - /* Fieldmap items are as follows: - "message": , diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt index c01d7e3a025..98142e21b20 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -27,6 +27,7 @@ import com.nextcloud.talk.models.json.threads.ThreadsOverall import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall import okhttp3.MultipartBody import okhttp3.RequestBody +import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.Field @@ -367,4 +368,11 @@ interface NcApiCoroutines { @GET suspend fun getScheduledMessage(@Header("Authorization") authorization: String, @Url url: String): ChatOverall + + @GET + suspend fun pullChatMessages( + @Header("Authorization") authorization: String, + @Url url: String, + @QueryMap fields: Map + ): Response } diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index ef978b9b843..62a2617df69 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -61,8 +61,10 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ContextThemeWrapper import androidx.cardview.widget.CardView import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.coordinatorlayout.widget.CoordinatorLayout @@ -81,8 +83,13 @@ 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.Lifecycle import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -141,6 +148,7 @@ import com.nextcloud.talk.contextchat.ContextChatViewModel import com.nextcloud.talk.conversationinfo.ConversationInfoActivity import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityChatBinding @@ -156,6 +164,7 @@ import com.nextcloud.talk.messagesearch.MessageSearchActivity import com.nextcloud.talk.models.ExternalSignalingServer import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.capabilities.SpreedCapability +import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.models.json.chat.ReadStatus import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.participants.Participant @@ -168,11 +177,11 @@ import com.nextcloud.talk.signaling.SignalingMessageReceiver import com.nextcloud.talk.signaling.SignalingMessageSender import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity import com.nextcloud.talk.translate.ui.TranslateActivity -import com.nextcloud.talk.ui.PinnedMessageView import com.nextcloud.talk.ui.PlaybackSpeed import com.nextcloud.talk.ui.PlaybackSpeedControl import com.nextcloud.talk.ui.StatusDrawable import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet +import com.nextcloud.talk.ui.chat.GetNewChatView import com.nextcloud.talk.ui.dialog.DateTimeCompose import com.nextcloud.talk.ui.dialog.FileAttachmentPreviewFragment import com.nextcloud.talk.ui.dialog.GetPinnedOptionsDialog @@ -182,6 +191,8 @@ import com.nextcloud.talk.ui.dialog.ShowReactionsDialog import com.nextcloud.talk.ui.dialog.TempMessageActionsDialog import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback +import com.nextcloud.talk.ui.theme.LocalMessageUtils +import com.nextcloud.talk.ui.theme.LocalViewThemeUtils import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.AudioUtils import com.nextcloud.talk.utils.CapabilitiesUtil @@ -233,10 +244,14 @@ 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.collectLatest +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -293,7 +308,19 @@ class ChatActivity : @Inject lateinit var networkMonitor: NetworkMonitor - lateinit var chatViewModel: ChatViewModel + @Inject + lateinit var chatViewModelFactory: ChatViewModel.ChatViewModelFactory + + var useJetpackCompose = true + + val chatViewModel: ChatViewModel by viewModels { + ViewModelFactoryWithParams(ChatViewModel::class.java) { + chatViewModelFactory.create( + roomToken, + conversationThreadId + ) + } + } lateinit var conversationInfoViewModel: ConversationInfoViewModel lateinit var contextChatViewModel: ContextChatViewModel @@ -356,7 +383,12 @@ class ChatActivity : messageId = messageId!!, title = currentConversation!!.displayName ) - ContextChatView(context, contextChatViewModel) + ContextChatView( + user = conversationUser, + context = context, + viewThemeUtils = viewThemeUtils, + contextViewModel = contextChatViewModel + ) } } Log.d(TAG, "Should open something else") @@ -376,8 +408,18 @@ class ChatActivity : val disposables = DisposableSet() var sessionIdAfterRoomJoined: String? = null - lateinit var roomToken: String - var conversationThreadId: Long? = null + + val roomToken: String by lazy { + intent.getStringExtra(KEY_ROOM_TOKEN) + ?: error("roomToken missing") + } + + val conversationThreadId: Long? by lazy { + if (intent.hasExtra(KEY_THREAD_ID)) { + intent.getLongExtra(KEY_THREAD_ID, 0L) + } else null + } + var openedViaNotification: Boolean = false var conversationThreadInfo: ThreadInfo? = null lateinit var conversationUser: User @@ -437,15 +479,15 @@ class ChatActivity : var callStarted = false - private val localParticipantMessageListener = object : SignalingMessageReceiver.LocalParticipantMessageListener { - override fun onSwitchTo(token: String?) { - if (token != null) { - if (CallActivity.active) { - Log.d(TAG, "CallActivity is running. Ignore to switch chat in ChatActivity...") - } else { - switchToRoom(token, false, false) - } - } + private val localParticipantMessageListener = SignalingMessageReceiver.LocalParticipantMessageListener { token -> + if (CallActivity.active) { + Log.d(TAG, "CallActivity is running. Ignore to switch chat in ChatActivity...") + } else { + switchToRoom( + token = token, + startCallAfterRoomSwitch = false, + isVoiceOnlyCall = false + ) } } @@ -485,6 +527,17 @@ class ChatActivity : updateTypingIndicator() } } + + override fun onChatMessageReceived(chatMessage: ChatMessageJson) { + chatViewModel.onSignalingChatMessageReceived(chatMessage) + + Log.d( + TAG, + "received message in ChatActivity. This is the chat message received via HPB. It would be " + + "nicer to receive it in the ViewModel or Repository directly. " + + "Otherwise it needs to be passed into it from here..." + ) + } } override fun onCreate(savedInstanceState: Bundle?) { @@ -495,6 +548,10 @@ class ChatActivity : setupActionBar() setContentView(binding.root) + binding.progressBar.visibility = View.GONE + binding.offline.root.visibility = View.GONE + binding.messagesListView.visibility = View.VISIBLE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { ViewCompat.setOnApplyWindowInsetsListener(binding.chatContainer) { view, insets -> val systemBarInsets = insets.getInsets( @@ -519,12 +576,14 @@ class ChatActivity : colorizeNavigationBar() } - chatViewModel = ViewModelProvider(this, viewModelFactory)[ChatViewModel::class.java] - conversationInfoViewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java] contextChatViewModel = ViewModelProvider(this, viewModelFactory)[ContextChatViewModel::class.java] + if (useJetpackCompose) { + setChatListContent() + } + lifecycleScope.launch { currentUserProvider.getCurrentUser() .onSuccess { user -> @@ -532,11 +591,11 @@ class ChatActivity : handleIntent(intent) val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken) val credentials = ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token) + // TODO init via viewModel parameters, just like it's done for roomToken chatViewModel.initData( user, credentials!!, urlForChatting, - roomToken, conversationThreadId ) @@ -567,10 +626,62 @@ 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 chatItems by chatViewModel.chatItems.collectAsStateWithLifecycle(emptyList()) + + binding.messagesListViewCompose.visibility = View.VISIBLE + binding.messagesListView.visibility = View.GONE + + CompositionLocalProvider( + LocalViewThemeUtils provides viewThemeUtils, + LocalMessageUtils provides messageUtils + ) { + GetNewChatView( + chatItems = chatItems, + conversationThreadId = conversationThreadId, + onLoadMore = { loadMoreMessagesCompose() } + ) + } + } + } + + 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) + advanceLocalLastReadMessageIfNeeded() + } else { + Log.e(TAG, "adapter was null") + } + } + } + private fun getMessageInputFragment(): MessageInputFragment { val internalId = conversationUser!!.id.toString() + "@" + roomToken return MessageInputFragment().apply { @@ -603,14 +714,6 @@ class ChatActivity : private fun handleIntent(intent: Intent) { val extras: Bundle? = intent.extras - roomToken = extras?.getString(KEY_ROOM_TOKEN).orEmpty() - - conversationThreadId = if (extras?.containsKey(KEY_THREAD_ID) == true) { - extras.getLong(KEY_THREAD_ID) - } else { - null - } - openedViaNotification = extras?.getBoolean(KEY_OPENED_VIA_NOTIFICATION) ?: false sharedText = extras?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty() @@ -656,22 +759,53 @@ class ChatActivity : this.lifecycle.removeObserver(chatViewModel) } + @OptIn(FlowPreview::class) @OptIn(ExperimentalCoroutinesApi::class) @SuppressLint("NotifyDataSetChanged", "SetTextI18n", "ResourceAsColor") @Suppress("LongMethod") private fun initObservers() { Log.d(TAG, "initObservers Called") + + 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 -> {} + } + } + } + } + } + lifecycleScope.launch { chatViewModel.getConversationFlow .onEach { conversationModel -> currentConversation = conversationModel + + // this should be updated in viewModel directly! chatViewModel.updateConversation(conversationModel) + logConversationInfos("GetRoomSuccessState") - if (adapter == null) { + if (adapter == null && !useJetpackCompose) { initAdapter() binding.messagesListView.setAdapter(adapter) layoutManager = binding.messagesListView.layoutManager as? LinearLayoutManager + + setChatListContentForChatKit() } chatViewModel.getCapabilities(conversationUser!!, roomToken, conversationModel) @@ -850,10 +984,14 @@ class ChatActivity : val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken) - chatViewModel.loadMessages( - withCredentials = credentials!!, - withUrl = urlForChatting - ) + lifecycleScope.launch { + chatViewModel.loadInitialMessages( + withCredentials = credentials!!, + withUrl = urlForChatting, + hasHighPerformanceBackend = + WebSocketConnectionHelper.getWebSocketInstanceForUser(conversationUser) != null + ) + } } else { Log.w( TAG, @@ -1007,7 +1145,7 @@ class ChatActivity : val id = state.msg.ocs!!.data!!.parentMessage!!.id.toString() val index = adapter?.getMessagePositionById(id) ?: 0 - val message = adapter?.items?.get(index)?.item as ChatMessage + val message = adapter?.items?.get(index)?.item as? ChatMessage setMessageAsDeleted(message) } @@ -1041,77 +1179,6 @@ class ChatActivity : } } - chatViewModel.chatMessageViewState.observe(this) { state -> - when (state) { - is ChatViewModel.ChatMessageStartState -> { - // Handle UI on first load - cancelNotificationsForCurrentConversation() - binding.progressBar.visibility = View.GONE - binding.offline.root.visibility = View.GONE - binding.messagesListView.visibility = View.VISIBLE - collapseSystemMessages() - } - - is ChatViewModel.ChatMessageUpdateState -> { - binding.progressBar.visibility = View.GONE - binding.offline.root.visibility = View.GONE - binding.messagesListView.visibility = View.VISIBLE - } - - is ChatViewModel.ChatMessageErrorState -> { - // unused atm - } - - else -> {} - } - } - - this.lifecycleScope.launch { - chatViewModel.getMessageFlow - .onEach { triple -> - val lookIntoFuture = triple.first - val setUnreadMessagesMarker = triple.second - var chatMessageList = triple.third - - chatMessageList = handleSystemMessages(chatMessageList) - chatMessageList = handleThreadMessages(chatMessageList) - if (chatMessageList.isEmpty()) { - return@onEach - } - - determinePreviousMessageIds(chatMessageList) - - handleExpandableSystemMessages(chatMessageList) - - if (ChatMessage.SystemMessageType.CLEARED_CHAT == chatMessageList[0].systemMessageType) { - adapter?.clear() - adapter?.notifyDataSetChanged() - } - - if (lookIntoFuture) { - Log.d(TAG, "chatMessageList.size in getMessageFlow:" + chatMessageList.size) - processMessagesFromTheFuture(chatMessageList, setUnreadMessagesMarker) - } else { - processMessagesNotFromTheFuture(chatMessageList) - collapseSystemMessages() - } - - processExpiredMessages() - processCallStartedMessages() - - adapter?.notifyDataSetChanged() - } - .collect() - } - - this.lifecycleScope.launch { - chatViewModel.getRemoveMessageFlow - .onEach { - removeMessageById(it.id) - } - .collect() - } - this.lifecycleScope.launch { chatViewModel.getUpdateMessageFlow .onEach { @@ -1137,20 +1204,6 @@ class ChatActivity : .collect() } - this.lifecycleScope.launch { - chatViewModel.getGeneralUIFlow.onEach { key -> - when (key) { - NO_OFFLINE_MESSAGES_FOUND -> { - binding.progressBar.visibility = View.GONE - binding.messagesListView.visibility = View.GONE - binding.offline.root.visibility = View.VISIBLE - } - - else -> {} - } - }.collect() - } - this.lifecycleScope.launch { chatViewModel.mediaPlayerSeekbarObserver.onEach { msg -> adapter?.update(msg) @@ -1530,6 +1583,8 @@ class ChatActivity : super.onScrollStateChanged(recyclerView, newState) if (newState == AbsListView.OnScrollListener.SCROLL_STATE_IDLE) { + advanceLocalLastReadMessageIfNeeded() + updateRemoteLastReadMessageIfNeeded() if (isScrolledToBottom()) { binding.unreadMessagesPopup.visibility = View.GONE binding.scrollDownButton.visibility = View.GONE @@ -2837,9 +2892,40 @@ 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? + updateRemoteLastReadMessageIfNeeded() + adapter = null } + private fun advanceLocalLastReadMessageIfNeeded() { + 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 -> + chatViewModel.advanceLocalLastReadMessageIfNeeded(messageId) + } + } + } + + private fun updateRemoteLastReadMessageIfNeeded() { + val url = ApiUtils.getUrlForChatReadMarker( + ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(ApiUtils.API_V1)), + conversationUser.baseUrl!!, + roomToken + ) + + chatViewModel.updateRemoteLastReadMessageIfNeeded( + credentials = credentials!!, + url = url + ) + } + private fun isActivityNotChangingConfigurations(): Boolean = !isChangingConfigurations private fun isNotInCall(): Boolean = @@ -2966,6 +3052,7 @@ class ChatActivity : private fun setupWebsocket() { if (currentConversation == null || conversationUser == null) { + Log.e(TAG, "setupWebsocket: currentConversation or conversationUser is null") return } @@ -3123,62 +3210,62 @@ class ChatActivity : } } - private fun processMessagesFromTheFuture(chatMessageList: List, setUnreadMessagesMarker: Boolean) { - binding.scrollDownButton.visibility = View.GONE - - val scrollToBottom: Boolean - - if (setUnreadMessagesMarker) { - scrollToBottom = false - setUnreadMessageMarker(chatMessageList) - } else { - if (isScrolledToBottom()) { - scrollToBottom = true - } else { - scrollToBottom = false - binding.unreadMessagesPopup.visibility = View.VISIBLE - // here we have the problem that the chat jumps for every update - } - } - - var shouldRefreshRoom = false - - for (chatMessage in chatMessageList) { - chatMessage.activeUser = conversationUser - - adapter?.let { - val previousChatMessage = it.items?.getOrNull(1)?.item - if (previousChatMessage != null && previousChatMessage is ChatMessage) { - chatMessage.isGrouped = groupMessages(chatMessage, previousChatMessage) - } - chatMessage.isOneToOneConversation = - (currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) - chatMessage.isFormerOneToOneConversation = - (currentConversation?.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE) - Log.d(TAG, "chatMessage to add:" + chatMessage.message) - it.addToStart(chatMessage, scrollToBottom) - } - - val systemMessageType = chatMessage.systemMessageType - if (systemMessageType != null && - ( - systemMessageType == ChatMessage.SystemMessageType.MESSAGE_PINNED || - systemMessageType == ChatMessage.SystemMessageType.MESSAGE_UNPINNED - ) - ) { - shouldRefreshRoom = true - } - } - - if (shouldRefreshRoom) { - chatViewModel.refreshRoom() - } - - // workaround to jump back to unread messages marker - if (setUnreadMessagesMarker) { - scrollToFirstUnreadMessage() - } - } + // private fun processMessagesFromTheFuture(chatMessageList: List, setUnreadMessagesMarker: Boolean) { + // binding.scrollDownButton.visibility = View.GONE + // + // val scrollToBottom: Boolean + // + // if (setUnreadMessagesMarker) { + // scrollToBottom = false + // setUnreadMessageMarker(chatMessageList) + // } else { + // if (isScrolledToBottom()) { + // scrollToBottom = true + // } else { + // scrollToBottom = false + // binding.unreadMessagesPopup.visibility = View.VISIBLE + // // here we have the problem that the chat jumps for every update + // } + // } + // + // var shouldRefreshRoom = false + // + // for (chatMessage in chatMessageList) { + // chatMessage.activeUser = conversationUser + // + // adapter?.let { + // val previousChatMessage = it.items?.getOrNull(1)?.item + // if (previousChatMessage != null && previousChatMessage is ChatMessage) { + // chatMessage.isGrouped = groupMessages(chatMessage, previousChatMessage) + // } + // chatMessage.isOneToOneConversation = + // (currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) + // chatMessage.isFormerOneToOneConversation = + // (currentConversation?.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE) + // Log.d(TAG, "chatMessage to add:" + chatMessage.message) + // it.addToStart(chatMessage, scrollToBottom) + // } + // + // val systemMessageType = chatMessage.systemMessageType + // if (systemMessageType != null && + // ( + // systemMessageType == ChatMessage.SystemMessageType.MESSAGE_PINNED || + // systemMessageType == ChatMessage.SystemMessageType.MESSAGE_UNPINNED + // ) + // ) { + // shouldRefreshRoom = true + // } + // } + // + // if (shouldRefreshRoom) { + // chatViewModel.refreshRoom() + // } + // + // // workaround to jump back to unread messages marker + // if (setUnreadMessagesMarker) { + // scrollToFirstUnreadMessage() + // } + // } private fun isScrolledToBottom(): Boolean { val position = layoutManager?.findFirstVisibleItemPosition() @@ -3205,26 +3292,26 @@ class ChatActivity : } } - private fun processMessagesNotFromTheFuture(chatMessageList: List) { - for (i in chatMessageList.indices) { - if (chatMessageList.size > i + 1) { - chatMessageList[i].isGrouped = groupMessages(chatMessageList[i], chatMessageList[i + 1]) - } - - val chatMessage = chatMessageList[i] - chatMessage.isOneToOneConversation = - currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL - chatMessage.isFormerOneToOneConversation = - (currentConversation?.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE) - chatMessage.activeUser = conversationUser - chatMessage.token = roomToken - } - - if (adapter != null) { - adapter?.addToEnd(chatMessageList, false) - } - scrollToRequestedMessageIfNeeded() - } + // private fun processMessagesNotFromTheFuture(chatMessageList: List) { + // for (i in chatMessageList.indices) { + // if (chatMessageList.size > i + 1) { + // chatMessageList[i].isGrouped = groupMessages(chatMessageList[i], chatMessageList[i + 1]) + // } + // + // val chatMessage = chatMessageList[i] + // chatMessage.isOneToOneConversation = + // currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + // chatMessage.isFormerOneToOneConversation = + // (currentConversation?.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE) + // chatMessage.activeUser = conversationUser + // chatMessage.token = roomToken + // } + // + // if (adapter != null) { + // adapter?.addToEnd(chatMessageList, false) + // } + // scrollToRequestedMessageIfNeeded() + // } private fun scrollToFirstUnreadMessage() { adapter?.let { @@ -3232,38 +3319,7 @@ class ChatActivity : } } - private fun groupMessages(message1: ChatMessage, message2: ChatMessage): Boolean { - val message1IsSystem = message1.systemMessage.isNotEmpty() - val message2IsSystem = message2.systemMessage.isNotEmpty() - if (message1IsSystem != message2IsSystem) { - return false - } - - if (message1.actorType == "bots" && message1.actorId != "changelog") { - return false - } - - if (!message1IsSystem && - ( - (message1.actorType != message2.actorType) || - (message2.actorId != message1.actorId) - ) - ) { - return false - } - - val timeDifference = dateUtils.getTimeDifferenceInSeconds( - message2.timestamp, - message1.timestamp - ) - val isLessThan5Min = timeDifference > FIVE_MINUTES_IN_SECONDS - return isSameDayMessages(message2, message1) && - (message2.actorId == message1.actorId) && - (!isLessThan5Min) && - (message2.lastEditTimestamp == 0L || message1.lastEditTimestamp == 0L) - } - - private fun determinePreviousMessageIds(chatMessageList: List) { + private fun determinePreviousMessageIds(chatMessageList: List): List { var previousMessageId = NO_PREVIOUS_MESSAGE_ID for (i in chatMessageList.indices.reversed()) { val chatMessage = chatMessageList[i] @@ -3284,6 +3340,7 @@ class ChatActivity : previousMessageId = chatMessage.jsonMessageId } + return chatMessageList } private fun getItemFromAdapter(messageId: String): Pair? { @@ -3292,7 +3349,7 @@ class ChatActivity : it.item is ChatMessage && (it.item as ChatMessage).id == messageId } if (messagePosition >= 0) { - val currentItem = adapter?.items?.get(messagePosition)?.item + val currentItem = adapter?.items?.getOrNull(messagePosition)?.item if (currentItem is ChatMessage && currentItem.id == messageId) { return Pair(currentItem, messagePosition) } else { @@ -3321,6 +3378,64 @@ class ChatActivity : private fun isSameDayMessages(message1: ChatMessage, message2: ChatMessage): Boolean = DateFormatter.isSameDay(message1.createdAt, message2.createdAt) + + private fun loadMoreMessagesCompose() { + val currentItems = chatViewModel.chatItems.value + + val messageId = currentItems + .asReversed() + .firstNotNullOfOrNull { item -> + (item as? ChatViewModel.ChatItem.MessageItem) + ?.message + ?.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 + ) + } + } + + // private fun loadMoreMessagesCompose() { + // val currentMessages = chatViewModel.chatItems.value + // + // val messageId = currentMessages + // .lastOrNull() + // ?.jsonMessageId + // + // Log.d("newchat", "Compose load more, messageId: $messageId") + // + // messageId?.let { + // val urlForChatting = ApiUtils.getUrlForChat( + // chatApiVersion, + // conversationUser?.baseUrl, + // roomToken + // ) + // + // chatViewModel.loadMoreMessages( + // beforeMessageId = it.toLong(), + // withUrl = urlForChatting, + // withCredentials = credentials!!, + // withMessageLimit = MESSAGE_PULL_LIMIT, + // roomToken = currentConversation!!.token + // ) + // } + // } + + @Deprecated("old adapter solution") override fun onLoadMore(page: Int, totalItemsCount: Int) { val messageId = ( adapter?.items @@ -3328,6 +3443,8 @@ class ChatActivity : ?.item as? ChatMessage )?.jsonMessageId + Log.d("newchat", "onLoadMore with messageId: " + messageId + " page:$page totalItemsCount:$totalItemsCount") + messageId?.let { val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken) @@ -3898,6 +4015,55 @@ class ChatActivity : return chatMessageMap.values.toList() } + private fun groupAndEnrichMessages(chatMessageList: List): List { + fun groupMessages(message1: ChatMessage, message2: ChatMessage): Boolean { + val message1IsSystem = message1.systemMessage.isNotEmpty() + val message2IsSystem = message2.systemMessage.isNotEmpty() + if (message1IsSystem != message2IsSystem) { + return false + } + + if (message1.actorType == "bots" && message1.actorId != "changelog") { + return false + } + + if (!message1IsSystem && + ( + (message1.actorType != message2.actorType) || + (message2.actorId != message1.actorId) + ) + ) { + return false + } + + val timeDifference = dateUtils.getTimeDifferenceInSeconds( + message2.timestamp, + message1.timestamp + ) + val isLessThan5Min = timeDifference > FIVE_MINUTES_IN_SECONDS + return isSameDayMessages(message2, message1) && + (message2.actorId == message1.actorId) && + (!isLessThan5Min) && + (message2.lastEditTimestamp == 0L || message1.lastEditTimestamp == 0L) + } + + for (i in chatMessageList.indices) { + if (chatMessageList.size > i + 1) { + chatMessageList[i].isGrouped = groupMessages(chatMessageList[i], chatMessageList[i + 1]) + } + val chatMessage = chatMessageList[i] + + chatMessage.isOneToOneConversation = + currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + chatMessage.isFormerOneToOneConversation = + (currentConversation?.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE) + + chatMessage.activeUser = conversationUser + chatMessage.token = roomToken + } + return chatMessageList + } + private fun groupSystemMessages(previousMessage: ChatMessage, currentMessage: ChatMessage) { previousMessage.expandableParent = true currentMessage.expandableParent = false @@ -4142,7 +4308,10 @@ class ChatActivity : binding.genericComposeView.apply { val shouldDismiss = mutableStateOf(false) setContent { - DateTimeCompose(bundle).GetDateTimeDialog(shouldDismiss, this@ChatActivity) + DateTimeCompose( + bundle, + chatViewModel + ).GetDateTimeDialog(shouldDismiss, this@ChatActivity) } } } @@ -4173,10 +4342,25 @@ class ChatActivity : chatViewModel.unPinMessage(credentials!!, url) } + private fun markAsRead(messageId: Int) { + chatViewModel.setChatReadMessage( + credentials!!, + ApiUtils.getUrlForChatReadMarker( + ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(ApiUtils.API_V1)), + conversationUser?.baseUrl!!, + roomToken + ), + messageId + ) + } + fun markAsUnread(message: IMessage?) { val chatMessage = message as ChatMessage? if (chatMessage!!.previousMessageId > NO_PREVIOUS_MESSAGE_ID) { - chatViewModel.setChatReadMarker( + // previousMessageId is taken to mark chat as unread even when "chat-unread" capability is not available + // It should be checked if "chat-unread" capability is available and then use + // https://nextcloud-talk.readthedocs.io/en/latest/chat/#mark-chat-as-unread + chatViewModel.setChatReadMessage( credentials!!, ApiUtils.getUrlForChatReadMarker( ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(ApiUtils.API_V1)), diff --git a/app/src/main/java/com/nextcloud/talk/chat/UnreadMessagesPopup.kt b/app/src/main/java/com/nextcloud/talk/chat/UnreadMessagesPopup.kt new file mode 100644 index 00000000000..c61c7b47532 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/UnreadMessagesPopup.kt @@ -0,0 +1,57 @@ +/* + * 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..415cc292683 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, hasHighPerformanceBackend: Boolean) + + suspend fun startMessagePolling(hasHighPerformanceBackend: Boolean) /** * Loads messages from local storage. If the messages are not found, then it @@ -60,19 +63,12 @@ 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. @@ -152,4 +148,8 @@ interface ChatMessageRepository : LifecycleAwareManager { suspend fun deleteScheduledChatMessage(credentials: String, url: String): Flow> suspend fun getScheduledChatMessages(credentials: String, url: String): Flow>> + + suspend fun onSignalingChatMessageReceived(chatMessage: ChatMessageJson) + + fun observeMessages(internalConversationId: String): Flow> } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt index c856ca6977d..ba5655d111c 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 @@ -144,7 +144,9 @@ data class ChatMessage( var pinnedUntil: Long? = null, - var sendAt: Int? = null + var sendAt: Int? = null, + + var avatarUrl: String? = null ) : MessageContentType, MessageContentType.Image { @@ -211,28 +213,33 @@ data class ChatMessage( @Suppress("ReturnCount") fun isLinkPreview(): Boolean { - if (CapabilitiesUtil.isLinkPreviewAvailable(activeUser!!)) { - val regexStringFromServer = activeUser?.capabilities?.coreCapability?.referenceRegex - - val regexFromServer = regexStringFromServer?.toRegex(setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE)) - val regexDefault = REGEX_STRING_DEFAULT.toRegex(setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE)) - - val messageCharSequence: CharSequence = StringBuffer(message!!) + activeUser?.let { + if (CapabilitiesUtil.isLinkPreviewAvailable(it)) { + val regexStringFromServer = activeUser?.capabilities?.coreCapability?.referenceRegex + + val regexFromServer = regexStringFromServer?.toRegex( + setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE) + ) + val regexDefault = REGEX_STRING_DEFAULT.toRegex(setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE)) + + val messageCharSequence: CharSequence = StringBuffer(message!!) + + if (regexFromServer != null) { + val foundLinkInServerRegex = regexFromServer.containsMatchIn(messageCharSequence) + if (foundLinkInServerRegex) { + extractedUrlToPreview = regexFromServer.find(messageCharSequence)?.groups?.get(0)?.value?.trim() + return true + } + } - if (regexFromServer != null) { - val foundLinkInServerRegex = regexFromServer.containsMatchIn(messageCharSequence) - if (foundLinkInServerRegex) { - extractedUrlToPreview = regexFromServer.find(messageCharSequence)?.groups?.get(0)?.value?.trim() + val foundLinkInDefaultRegex = regexDefault.containsMatchIn(messageCharSequence) + if (foundLinkInDefaultRegex) { + extractedUrlToPreview = regexDefault.find(messageCharSequence)?.groups?.get(0)?.value?.trim() return true } } - - val foundLinkInDefaultRegex = regexDefault.containsMatchIn(messageCharSequence) - if (foundLinkInDefaultRegex) { - extractedUrlToPreview = regexDefault.find(messageCharSequence)?.groups?.get(0)?.value?.trim() - return true - } } + return false } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt index dfd437e24db..6c6a3ebeadf 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt @@ -64,7 +64,12 @@ interface ChatNetworkDataSource { threadTitle: String? ): ChatOverallSingleMessage - fun pullChatMessages(credentials: String, url: String, fieldMap: HashMap): Observable> + suspend fun pullChatMessages( + credentials: String, + url: String, + fieldMap: HashMap + ): Response + fun deleteChatMessage(credentials: String, url: String): Observable fun createRoom(credentials: String, url: String, map: Map): Observable fun setChatReadMarker(credentials: String, url: String, previousMessageId: Int): Observable diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt index e77cbadc607..589f64d8131 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,9 +10,9 @@ 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 @@ -25,31 +25,33 @@ 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.first import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.timeout +import retrofit2.HttpException import java.io.IOException import javax.inject.Inject +import kotlin.collections.map +import kotlin.time.Duration.Companion.microseconds @Suppress("LargeClass", "TooManyFunctions") class OfflineFirstChatRepository @Inject constructor( @@ -99,20 +101,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 +121,8 @@ class OfflineFirstChatRepository @Inject constructor( private lateinit var urlForChatting: String private var threadId: Long? = null + private var latestKnownMessageIdFromSync: Long = 0 + override fun initData( currentUser: User, credentials: String, @@ -139,103 +142,108 @@ class OfflineFirstChatRepository @Inject constructor( this.conversationModel = conversationModel } - override fun initScopeAndLoadInitialMessages(withNetworkParams: Bundle) { - scope = CoroutineScope(Dispatchers.IO) - loadInitialMessages(withNetworkParams) - } + override suspend fun loadInitialMessages(withNetworkParams: Bundle, hasHighPerformanceBackend: Boolean) { + Log.d(TAG, "---- loadInitialMessages ------------") + newXChatLastCommonRead = conversationModel.lastCommonReadMessage + + Log.d(TAG, "conversationModel.internalId: " + conversationModel.internalId) + Log.d(TAG, "conversationModel.lastReadMessage:" + conversationModel.lastReadMessage) - private fun loadInitialMessages(withNetworkParams: Bundle): Job = - scope.launch { - Log.d(TAG, "---- loadInitialMessages ------------") - newXChatLastCommonRead = conversationModel.lastCommonReadMessage + var newestMessageIdFromDb = chatBlocksDao.getNewestMessageIdFromChatBlocks(internalConversationId, threadId) + Log.d(TAG, "newestMessageIdFromDb: $newestMessageIdFromDb") - Log.d(TAG, "conversationModel.internalId: " + conversationModel.internalId) - Log.d(TAG, "conversationModel.lastReadMessage:" + conversationModel.lastReadMessage) + val weAlreadyHaveSomeOfflineMessages = newestMessageIdFromDb > 0 - var newestMessageIdFromDb = chatBlocksDao.getNewestMessageIdFromChatBlocks(internalConversationId, threadId) - Log.d(TAG, "newestMessageIdFromDb: $newestMessageIdFromDb") + val weHaveAtLeastTheLastReadMessage = newestMessageIdFromDb >= conversationModel.lastReadMessage.toLong() + Log.d(TAG, "weAlreadyHaveSomeOfflineMessages:$weAlreadyHaveSomeOfflineMessages") + Log.d(TAG, "weHaveAtLeastTheLastReadMessage:$weHaveAtLeastTheLastReadMessage") + Log.d(TAG, "hasHighPerformanceBackend:$hasHighPerformanceBackend") - val weAlreadyHaveSomeOfflineMessages = newestMessageIdFromDb > 0 - val weHaveAtLeastTheLastReadMessage = newestMessageIdFromDb >= conversationModel.lastReadMessage.toLong() - Log.d(TAG, "weAlreadyHaveSomeOfflineMessages:$weAlreadyHaveSomeOfflineMessages") - Log.d(TAG, "weHaveAtLeastTheLastReadMessage:$weHaveAtLeastTheLastReadMessage") + if (weAlreadyHaveSomeOfflineMessages && weHaveAtLeastTheLastReadMessage && !hasHighPerformanceBackend) { + Log.d( + TAG, + "Initial online request is skipped because offline messages are up to date" + + " until lastReadMessage" + ) - if (weAlreadyHaveSomeOfflineMessages && weHaveAtLeastTheLastReadMessage) { + // 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 (hasHighPerformanceBackend) { 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 HPB is used (No long " + + "polling available to catch up with messages newer than last read.)" ) - Log.d(TAG, "For messages newer than lastRead, lookIntoFuture will load them.") - } else { - if (!weAlreadyHaveSomeOfflineMessages) { - Log.d(TAG, "An online request for newest 100 messages is made because offline chat is empty") - if (networkMonitor.isOnline.value.not()) { - _generalUIFlow.emit(ChatActivity.NO_OFFLINE_MESSAGES_FOUND) - } - } else { - Log.d( - TAG, - "An online request for newest 100 messages is made because we don't have the lastReadMessage " + - "(gaps could be closed by scrolling up to merge the chatblocks)" - ) + } else if (!weAlreadyHaveSomeOfflineMessages) { + Log.d(TAG, "An online request for newest 100 messages is made because offline chat is empty") + if (networkMonitor.isOnline.value.not()) { + // _generalUIFlow.emit(ChatActivity.NO_OFFLINE_MESSAGES_FOUND) } - - // set up field map to load the newest messages - val fieldMap = getFieldMap( - lookIntoFuture = false, - timeout = 0, - includeLastKnown = true, - setReadMarker = true, - lastKnown = null + } else { + Log.d( + TAG, + "An online request for newest 100 messages is made because we don't have the lastReadMessage " + + "(gaps could be closed by scrolling up to merge the chatblocks)" ) - withNetworkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap) - withNetworkParams.putString(BundleKeys.KEY_ROOM_TOKEN, conversationModel.token) - - Log.d(TAG, "Starting online request for initial loading") - val chatMessageEntities = sync(withNetworkParams) - if (chatMessageEntities == null) { - Log.e(TAG, "initial loading of messages failed") - } - - newestMessageIdFromDb = chatBlocksDao.getNewestMessageIdFromChatBlocks(internalConversationId, threadId) - Log.d(TAG, "newestMessageIdFromDb after sync: $newestMessageIdFromDb") } - handleMessagesFromDb(newestMessageIdFromDb) - - initMessagePolling(newestMessageIdFromDb) - } - - private suspend fun handleMessagesFromDb(newestMessageIdFromDb: Long) { - if (newestMessageIdFromDb.toInt() != 0) { - val limit = getCappedMessagesAmountOfChatBlock(newestMessageIdFromDb) - - val list = getMessagesBeforeAndEqual( - messageId = newestMessageIdFromDb, - internalConversationId = internalConversationId, - messageLimit = limit + // set up field map to load the newest messages + val fieldMap = getFieldMap( + lookIntoFuture = false, + timeout = 0, + includeLastKnown = true, + lastKnown = null ) - if (list.isNotEmpty()) { - handleNewAndTempMessages( - receivedChatMessages = list, - lookIntoFuture = false, - showUnreadMessagesMarker = false - ) - } + withNetworkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap) + withNetworkParams.putString(BundleKeys.KEY_ROOM_TOKEN, conversationModel.token) - // this call could be deleted when we have a worker to send messages.. - sendUnsentChatMessages(credentials, urlForChatting) + Log.d(TAG, "Starting online request for initial loading") + getAndPersistMessages(withNetworkParams) + } - // delay is a dirty workaround to make sure messages are added to adapter on initial load before dealing - // with them (otherwise there is a race condition). - delay(DELAY_TO_ENSURE_MESSAGES_ARE_ADDED) + // handleMessagesFromDb(newestMessageIdFromDb) + } - updateUiForLastCommonRead() - updateUiForLastReadMessage(newestMessageIdFromDb) + override suspend fun startMessagePolling(hasHighPerformanceBackend: Boolean) { + if (hasHighPerformanceBackend) { + initInsuranceRequests() + } else { + initLongPolling() } } + // private suspend fun handleMessagesFromDb(newestMessageIdFromDb: Long) { + // if (newestMessageIdFromDb.toInt() != 0) { + // val limit = getCappedMessagesAmountOfChatBlock(newestMessageIdFromDb) + // + // val list = getMessagesBeforeAndEqual( + // messageId = newestMessageIdFromDb, + // internalConversationId = internalConversationId, + // messageLimit = limit + // ) + // if (list.isNotEmpty()) { + // handleNewAndTempMessages( + // receivedChatMessages = list, + // lookIntoFuture = false, + // showUnreadMessagesMarker = false + // ) + // } + // + // // this call could be deleted when we have a worker to send messages.. + // sendUnsentChatMessages(credentials, urlForChatting) + // + // // delay is a dirty workaround to make sure messages are added to adapter on initial load before dealing + // // with them (otherwise there is a race condition). + // delay(DELAY_TO_ENSURE_MESSAGES_ARE_ADDED) + // + // updateUiForLastCommonRead() + // updateUiForLastReadMessage(newestMessageIdFromDb) + // } + // } + private suspend fun getCappedMessagesAmountOfChatBlock(messageId: Long): Int { val chatBlock = getBlockOfMessage(messageId.toInt()) @@ -268,196 +276,200 @@ class OfflineFirstChatRepository @Inject constructor( } } - private fun updateUiForLastCommonRead() { - scope.launch { - newXChatLastCommonRead?.let { - _lastCommonReadFlow.emit(it) - } - } - } + // private fun updateUiForLastCommonRead() { + // scope.launch { + // newXChatLastCommonRead?.let { + // _lastCommonReadFlow.emit(it) + // } + // } + // } + + suspend fun initLongPolling() { + Log.d(TAG, "---- initLongPolling ------------") + + val initialMessageId = chatBlocksDao.getNewestMessageIdFromChatBlocks(internalConversationId, threadId) + Log.d(TAG, "initialMessageId for initLongPolling: $initialMessageId") + + var fieldMap = getFieldMap( + lookIntoFuture = true, + // timeout for first longpoll is 0, so "unread message" info is not shown if there were + // initially no messages but someone writes us in the first 30 seconds. + timeout = 0, + includeLastKnown = false, + lastKnown = initialMessageId.toInt() + ) - override fun loadMoreMessages( - beforeMessageId: Long, - roomToken: String, - withMessageLimit: Int, - withNetworkParams: Bundle - ): Job = - scope.launch { - Log.d(TAG, "---- loadMoreMessages for $beforeMessageId ------------") + val networkParams = Bundle() - val fieldMap = getFieldMap( - lookIntoFuture = false, - timeout = 0, - includeLastKnown = false, - setReadMarker = true, - lastKnown = beforeMessageId.toInt() - ) - withNetworkParams.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap) + var showUnreadMessagesMarker = true - val loadFromServer = hasToLoadPreviousMessagesFromServer(beforeMessageId, DEFAULT_MESSAGES_LIMIT) + 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 + ) - if (loadFromServer) { - Log.d(TAG, "Starting online request for loadMoreMessages") - sync(withNetworkParams) + showUnreadMessagesMarker = false } - - showMessagesBefore(internalConversationId, beforeMessageId, DEFAULT_MESSAGES_LIMIT) - updateUiForLastCommonRead() } + } - 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 +491,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 } @@ -489,26 +501,32 @@ class OfflineFirstChatRepository @Inject constructor( override suspend fun getMessage(messageId: Long, bundle: Bundle): Flow { Log.d(TAG, "Get message with id $messageId") - val loadFromServer = hasToLoadPreviousMessagesFromServer(messageId, 1) - if (loadFromServer) { + val localMessage = chatDao.getChatMessageOnce( + internalConversationId, + messageId + ) + + if (localMessage == null) { val fieldMap = getFieldMap( lookIntoFuture = false, timeout = 0, includeLastKnown = true, - setReadMarker = false, lastKnown = messageId.toInt(), limit = 1 ) bundle.putSerializable(BundleKeys.KEY_FIELD_MAP, fieldMap) - Log.d(TAG, "Starting online request for single message (e.g. a reply)") - sync(bundle) + Log.d(TAG, "Starting online request for single message") + getAndPersistMessages(bundle) } - return chatDao.getChatMessageForConversation( - internalConversationId, - messageId - ).map(ChatMessageEntity::asModel) + + return chatDao + .getChatMessageForConversationNullable(internalConversationId, messageId) + .mapNotNull { it?.asModel() } + .take(1) + .timeout(5_000.microseconds) + .catch { /* timeout -> emit nothing */ } } override suspend fun getParentMessageById(messageId: Long): Flow = @@ -517,124 +535,97 @@ class OfflineFirstChatRepository @Inject constructor( messageId ).map(ChatMessageEntity::asModel) - @Suppress("UNCHECKED_CAST", "MagicNumber", "Detekt.TooGenericExceptionCaught") - private fun getMessagesFromServer(bundle: Bundle): Pair>? { - val fieldMap = bundle.getSerializable(BundleKeys.KEY_FIELD_MAP) as HashMap - - var attempts = 1 - while (attempts < 5) { - Log.d(TAG, "message limit: " + fieldMap["limit"]) - try { - val result = network.pullChatMessages(credentials, urlForChatting, fieldMap) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .map { it -> - when (it.code()) { - HTTP_CODE_OK -> { - Log.d(TAG, "getMessagesFromServer HTTP_CODE_OK") - newXChatLastCommonRead = it.headers()["X-Chat-Last-Common-Read"]?.let { - Integer.parseInt(it) - } - - return@map Pair( - HTTP_CODE_OK, - (it.body() as ChatOverall).ocs!!.data!! - ) - } - - HTTP_CODE_NOT_MODIFIED -> { - Log.d(TAG, "getMessagesFromServer HTTP_CODE_NOT_MODIFIED") - - return@map Pair( - HTTP_CODE_NOT_MODIFIED, - listOf() - ) - } - - HTTP_CODE_PRECONDITION_FAILED -> { - Log.d(TAG, "getMessagesFromServer HTTP_CODE_PRECONDITION_FAILED") - - return@map Pair( - HTTP_CODE_PRECONDITION_FAILED, - listOf() - ) - } + fun pullMessagesFlow(bundle: Bundle): Flow = + flow { + val fieldMap = bundle.getSerializable(BundleKeys.KEY_FIELD_MAP) as HashMap + var attempts = 1 + + while (attempts < 5) { + runCatching { + network.pullChatMessages(credentials, urlForChatting, fieldMap) + }.fold( + onSuccess = { response -> + val result = when (response.code()) { + HTTP_CODE_OK -> ChatPullResult.Success( + messages = response.body()?.ocs?.data.orEmpty(), + lastCommonRead = response.headers()["X-Chat-Last-Common-Read"]?.toInt() + ) + HTTP_CODE_NOT_MODIFIED -> ChatPullResult.NotModified + HTTP_CODE_PRECONDITION_FAILED -> ChatPullResult.PreconditionFailed + else -> ChatPullResult.Error(HttpException(response)) + } - else -> { - return@map Pair( - HTTP_CODE_PRECONDITION_FAILED, - listOf() - ) - } + emit(result) + return@flow + }, + onFailure = { e -> + Log.e(TAG, "Attempt $attempts failed", e) + attempts++ + fieldMap["limit"] = when (attempts) { + 2 -> 50 + 3 -> 10 + else -> 5 } } - .blockingSingle() - return result - } catch (e: Exception) { - Log.e(TAG, "Something went wrong when pulling chat messages (attempt: $attempts)", e) - attempts++ - - val newMessageLimit = when (attempts) { - 2 -> 50 - 3 -> 10 - else -> 5 - } - fieldMap["limit"] = newMessageLimit + ) } - } - Log.e(TAG, "All attempts to get messages from server failed") - return null - } - private suspend fun sync(bundle: Bundle): List? { + emit(ChatPullResult.Error(IllegalStateException("All attempts failed"))) + }.flowOn(Dispatchers.IO) + + private suspend fun getAndPersistMessages(bundle: Bundle) { if (!networkMonitor.isOnline.value) { Log.d(TAG, "Device is offline, can't load chat messages from server") - return null } - val result = getMessagesFromServer(bundle) - if (result == null) { - Log.d(TAG, "No result from server") - return null - } - - var chatMessagesFromSync: List? = null - val fieldMap = bundle.getSerializable(BundleKeys.KEY_FIELD_MAP) as HashMap val queriedMessageId = fieldMap["lastKnownMessageId"] val lookIntoFuture = fieldMap["lookIntoFuture"] == 1 - val statusCode = result.first + val result = pullMessagesFlow(bundle).first() - val hasHistory = getHasHistory(statusCode, lookIntoFuture) + when (result) { + is ChatPullResult.Success -> { + val hasHistory = getHasHistory(HTTP_CODE_OK, lookIntoFuture) - Log.d( - TAG, - "internalConv=$internalConversationId statusCode=$statusCode lookIntoFuture=$lookIntoFuture " + - "hasHistory=$hasHistory " + - "queriedMessageId=$queriedMessageId" - ) + Log.d( + TAG, + "internalConv=$internalConversationId statusCode=${HTTP_CODE_OK} lookIntoFuture=$lookIntoFuture " + + "hasHistory=$hasHistory queriedMessageId=$queriedMessageId" + ) - val blockContainingQueriedMessage: ChatBlockEntity? = getBlockOfMessage(queriedMessageId) + val blockContainingQueriedMessage: ChatBlockEntity? = getBlockOfMessage(queriedMessageId) - if (blockContainingQueriedMessage != null && !hasHistory) { - blockContainingQueriedMessage.hasHistory = false - chatBlocksDao.upsertChatBlock(blockContainingQueriedMessage) - Log.d(TAG, "End of chat was reached so hasHistory=false is set") - } + blockContainingQueriedMessage?.takeIf { !hasHistory }?.apply { + this.hasHistory = false + chatBlocksDao.upsertChatBlock(this) + Log.d(TAG, "End of chat reached, set hasHistory=false") + } - if (result.second.isNotEmpty()) { - chatMessagesFromSync = updateMessagesData( - result.second, - blockContainingQueriedMessage, - lookIntoFuture, - hasHistory - ) - } else { - Log.d(TAG, "no data is updated...") - } + 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 +633,16 @@ class OfflineFirstChatRepository @Inject constructor( blockContainingQueriedMessage: ChatBlockEntity?, lookIntoFuture: Boolean, hasHistory: Boolean - ): List { - handleUpdateMessages(chatMessagesJson) - - val chatMessagesFromSyncToProcess = chatMessagesJson.map { - it.asEntity(currentUser.id!!) - } - - chatDao.upsertChatMessages(chatMessagesFromSyncToProcess) + ) { + val chatMessageEntities = persistChatMessagesAndHandleSystemMessages(chatMessagesJson) - val oldestIdFromSync = chatMessagesFromSyncToProcess.minByOrNull { it.id }!!.id - val newestIdFromSync = chatMessagesFromSyncToProcess.maxByOrNull { it.id }!!.id + val oldestIdFromSync = chatMessageEntities.minByOrNull { it.id }!!.id + val newestIdFromSync = chatMessageEntities.maxByOrNull { it.id }!!.id Log.d(TAG, "oldestIdFromSync: $oldestIdFromSync") Log.d(TAG, "newestIdFromSync: $newestIdFromSync") + latestKnownMessageIdFromSync = maxOf(latestKnownMessageIdFromSync, newestIdFromSync) + var oldestMessageIdForNewChatBlock = oldestIdFromSync var newestMessageIdForNewChatBlock = newestIdFromSync @@ -683,13 +670,11 @@ class OfflineFirstChatRepository @Inject constructor( newestMessageId = newestMessageIdForNewChatBlock, hasHistory = hasHistory ) - chatBlocksDao.upsertChatBlock(newChatBlock) // crash when no conversation thread exists! - + chatBlocksDao.upsertChatBlock(newChatBlock) updateBlocks(newChatBlock) - return chatMessagesFromSyncToProcess } - private suspend fun handleUpdateMessages(messagesJson: List) { + private suspend fun handleSystemMessagesThatAffectDatabase(messagesJson: List) { messagesJson.forEach { messageJson -> when (messageJson.systemMessageType) { ChatMessage.SystemMessageType.REACTION, @@ -712,7 +697,6 @@ class OfflineFirstChatRepository @Inject constructor( } chatDao.upsertChatMessage(parentMessageEntity) - _updateMessageFlow.emit(parentMessageEntity.asModel()) } } } @@ -768,7 +752,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 +761,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 +793,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 } } @@ -860,9 +841,6 @@ class OfflineFirstChatRepository @Inject constructor( override fun handleOnPause() { itIsPaused = true - if (this::scope.isInitialized) { - scope.cancel() - } } override fun handleOnResume() { @@ -1079,7 +1057,6 @@ 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 = @@ -1112,6 +1089,49 @@ 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.upsertChatMessages(chatMessageEntities) + + return chatMessageEntities + } + + override fun observeMessages(internalConversationId: String): Flow> = + chatBlocksDao + .getLatestChatBlock(internalConversationId, threadId) + .distinctUntilChanged() + .flatMapLatest { latestBlock -> + + if (latestBlock == null) { + flowOf(emptyList()) + } else { + chatDao.getMessagesNewerThan( + internalConversationId = internalConversationId, + threadId = threadId, + oldestMessageId = latestBlock.oldestMessageId + ) + } + } + @Suppress("LongParameterList") override suspend fun sendScheduledChatMessage( credentials: String, @@ -1246,6 +1266,7 @@ class OfflineFirstChatRepository @Inject constructor( private const val HALF_SECOND = 500L private const val DELAY_TO_ENSURE_MESSAGES_ARE_ADDED: Long = 100 private const val DEFAULT_MESSAGES_LIMIT = 100 - private const val MILLIES = 1000 + private const val MILLIES = 1000L + private const val INSURANCE_REQUEST_DELAY = 2 * 60 * MILLIES } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt index ef42ec91e47..07a694cd5b3 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt @@ -12,6 +12,7 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.capabilities.SpreedCapability import com.nextcloud.talk.models.json.chat.ChatMessageJson +import com.nextcloud.talk.models.json.chat.ChatOverall import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.generic.GenericOverall @@ -22,7 +23,6 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.message.SendMessageUtils import io.reactivex.Observable import retrofit2.Response -import com.nextcloud.talk.models.json.chat.ChatOverall class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines: NcApiCoroutines) : ChatNetworkDataSource { @@ -159,11 +159,11 @@ class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines: threadTitle ) - override fun pullChatMessages( + override suspend fun pullChatMessages( credentials: String, url: String, fieldMap: HashMap - ): Observable> = ncApi.pullChatMessages(credentials, url, fieldMap).map { it } + ): Response = ncApiCoroutines.pullChatMessages(credentials, url, fieldMap) override fun deleteChatMessage(credentials: String, url: String): Observable = ncApi.deleteChatMessage(credentials, url).map { diff --git a/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt b/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt new file mode 100644 index 00000000000..cb7a8ce0624 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/domain/ChatPullResult.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.domain + +import com.nextcloud.talk.models.json.chat.ChatMessageJson + +sealed class ChatPullResult { + data class Success(val messages: List, val lastCommonRead: Int?) : ChatPullResult() + + object NotModified : ChatPullResult() + object PreconditionFailed : ChatPullResult() + data class Error(val throwable: Throwable) : ChatPullResult() +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 41c0dd1b570..928cc1fe0cb 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 @@ -28,6 +28,7 @@ import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository 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.model.ChatMessageEntity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.toIntOrZero import com.nextcloud.talk.jobs.UploadAndShareFilesWorker @@ -36,6 +37,7 @@ 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.RoomOverall import com.nextcloud.talk.models.json.generic.GenericOverall @@ -50,27 +52,41 @@ 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 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.ExperimentalCoroutinesApi 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.asSharedFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.io.File +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId import javax.inject.Inject -@Suppress("TooManyFunctions", "LongParameterList", "LargeClass") -class ChatViewModel @Inject constructor( +@Suppress("TooManyFunctions", "LongParameterList") +class ChatViewModel @AssistedInject constructor( // should be removed here. Use it via RetrofitChatNetwork private val appPreferences: AppPreferences, private val chatNetworkDataSource: ChatNetworkDataSource, @@ -79,7 +95,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 { @@ -94,12 +113,17 @@ class ChatViewModel @Inject constructor( lateinit var currentUser: User + private var localLastReadMessage: Int = 0 + + private lateinit var currentConversation: ConversationModel + 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 = "" + + private val internalConversationId = MutableStateFlow(null) var messageDraft: MessageDraft = MessageDraft() lateinit var participantPermissions: ParticipantPermissions @@ -133,6 +157,12 @@ class ChatViewModel @Inject constructor( mediaPlayerManager.handleOnStop() } + fun onSignalingChatMessageReceived(chatMessage: ChatMessageJson) { + viewModelScope.launch { + chatRepository.onSignalingChatMessageReceived(chatMessage) + } + } + val backgroundPlayUIFlow = mediaPlayerManager.backgroundPlayUIFlow val mediaPlayerSeekbarObserver: Flow @@ -179,18 +209,10 @@ 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 - } + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() - val getRemoveMessageFlow = chatRepository.removeMessageFlow + // val getRemoveMessageFlow = chatRepository.removeMessageFlow val getUpdateMessageFlow = chatRepository.updateMessageFlow @@ -205,7 +227,7 @@ class ChatViewModel @Inject constructor( _getRoomViewState.value = GetRoomErrorState } - val getGeneralUIFlow = chatRepository.generalUIFlow + // val getGeneralUIFlow = chatRepository.generalUIFlow sealed interface ViewState @@ -301,21 +323,172 @@ class ChatViewModel @Inject constructor( val reactionDeletedViewState: LiveData get() = _reactionDeletedViewState - fun initData(user: User, credentials: String, urlForChatting: String, roomToken: String, threadId: Long?) { + init { + viewModelScope.launch { + currentUserProvider.getCurrentUser() + .onSuccess { user -> + internalConversationId.value = currentUser.id.toString() + "@" + chatRoomToken + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val chatItems: StateFlow> = + internalConversationId + .filterNotNull() + .flatMapLatest { conversationId -> + chatRepository.observeMessages(conversationId) + } + .map { entities -> + entities.map(ChatMessageEntity::asModel) + } + .onEach { messages -> + messages.forEach { + it.avatarUrl = getAvatarUrl(it) + it.incoming = it.actorId != currentUser.userId + } + } + .map { messages -> + messages + .let(::handleSystemMessages) + .let(::handleThreadMessages) + } + .map { messages -> + buildList { + var lastDate: LocalDate? = null + messages.asReversed().forEach { msg -> + val date = msg.dateKey() + if (date != lastDate) { + add(ChatItem.DateHeaderItem(date)) + lastDate = date + } + add(ChatItem.MessageItem(msg)) + } + }.asReversed() + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = emptyList() + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val messagesForChatKit: StateFlow> = + internalConversationId + .filterNotNull() + .flatMapLatest { conversationId -> + chatRepository.observeMessages(conversationId) + } + .map { entities -> entities.map(ChatMessageEntity::asModel) } + .onEach { messages -> + messages.forEach { + it.avatarUrl = getAvatarUrl(it) + it.incoming = it.actorId != currentUser.userId + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = emptyList() + ) + + 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() + } + + fun ChatMessage.dateKey(): LocalDate { + return Instant.ofEpochMilli(timestamp * 1000L) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + } + + 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) { + this.currentConversation = currentConversation chatRepository.updateConversation(currentConversation) + + advanceLocalLastReadMessageIfNeeded(currentConversation.lastReadMessage) } fun getRoom(token: String) { @@ -524,13 +697,19 @@ class ChatViewModel @Inject constructor( } } - fun loadMessages(withCredentials: String, withUrl: String) { + suspend fun loadInitialMessages(withCredentials: String, withUrl: String, hasHighPerformanceBackend: 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, + hasHighPerformanceBackend = hasHighPerformanceBackend ) + _events.emit(ChatEvent.StartRegularPolling) + } + + suspend fun startMessagePolling(hasHighPerformanceBackend: Boolean) { + chatRepository.startMessagePolling(hasHighPerformanceBackend) } fun loadMoreMessages( @@ -540,15 +719,17 @@ class ChatViewModel @Inject constructor( withCredentials: String, withUrl: String ) { - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_CHAT_URL, withUrl) - bundle.putString(BundleKeys.KEY_CREDENTIALS, withCredentials) - chatRepository.loadMoreMessages( - beforeMessageId, - roomToken, - withMessageLimit, - withNetworkParams = bundle - ) + viewModelScope.launch { + val bundle = Bundle() + bundle.putString(BundleKeys.KEY_CHAT_URL, withUrl) + bundle.putString(BundleKeys.KEY_CREDENTIALS, withCredentials) + chatRepository.loadMoreMessages( + beforeMessageId, + roomToken, + withMessageLimit, + withNetworkParams = bundle + ) + } } // fun initMessagePolling(withCredentials: String, withUrl: String, roomToken: String) { @@ -587,8 +768,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 > currentConversation.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 { @@ -1118,4 +1317,33 @@ class ChatViewModel @Inject constructor( data class Success(val thread: ThreadInfo?) : ThreadRetrieveUiState() data class Error(val exception: Exception) : ThreadRetrieveUiState() } + + sealed class ChatEvent { + object Initial : ChatEvent() + object StartRegularPolling : ChatEvent() + object Loading : ChatEvent() + object Ready : ChatEvent() + data class Error(val throwable: Throwable) : ChatEvent() + } + + sealed interface ChatItem { + fun messageOrNull(): ChatMessage? = (this as? MessageItem)?.message + + fun stableKey(): Any = + when (this) { + is MessageItem -> "msg_${message.id}" + is DateHeaderItem -> "header_$date" + } + + data class MessageItem(val message: ChatMessage) : ChatItem + data class DateHeaderItem(val date: LocalDate) : ChatItem + } + + @AssistedFactory + interface ChatViewModelFactory { + fun create( + roomToken: String, + conversationThreadId: Long? + ): ChatViewModel + } } diff --git a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt index 5fb014e2ba5..5e625078634 100644 --- a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -50,18 +49,27 @@ 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.user.model.User import com.nextcloud.talk.models.json.chat.ChatMessageJson -import com.nextcloud.talk.ui.ComposeChatAdapter +import com.nextcloud.talk.ui.chat.GetNewChatView +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 +104,8 @@ fun ContextChatErrorView() { @Composable fun ContextChatSuccessView( + user: User, + viewThemeUtils: ViewThemeUtils, visible: Boolean, context: Context, contextChatRetrieveUiStateSuccess: ContextChatViewModel.ContextChatRetrieveUiState.Success, @@ -171,15 +181,13 @@ fun ContextChatSuccessView( val messages = contextChatRetrieveUiStateSuccess.messages.map(ChatMessageJson::asModel) val messageId = contextChatRetrieveUiStateSuccess.messageId val threadId = contextChatRetrieveUiStateSuccess.threadId - val adapter = ComposeChatAdapter( - messagesJson = contextChatRetrieveUiStateSuccess.messages, - messageId = messageId, - threadId = threadId - ) - SideEffect { - adapter.addMessages(messages.toMutableList(), true) - } - adapter.GetView() + + // TODO refactor context chat + // GetNewChatView( + // chatItems = messages, + // conversationThreadId = threadId?.toLong(), + // null + // ) } } } diff --git a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt index e1aaf175e95..834dc7d4a30 100644 --- a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatViewModel.kt @@ -12,7 +12,6 @@ import androidx.lifecycle.viewModelScope import autodagger.AutoInjector import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource -import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.users.UserManager import kotlinx.coroutines.flow.MutableStateFlow @@ -24,9 +23,6 @@ import javax.inject.Inject class ContextChatViewModel @Inject constructor(private val chatNetworkDataSource: ChatNetworkDataSource) : ViewModel() { - @Inject - lateinit var chatViewModel: ChatViewModel - @Inject lateinit var userManager: UserManager diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 27050f040d2..f5af3befe2e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -39,7 +39,6 @@ import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat @@ -85,7 +84,6 @@ import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.chat.ChatActivity -import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contextchat.ContextChatView @@ -108,7 +106,6 @@ import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.settings.SettingsActivity import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity -import com.nextcloud.talk.ui.BackgroundVoiceMessageCard import com.nextcloud.talk.ui.dialog.ChooseAccountDialogCompose import com.nextcloud.talk.ui.dialog.ChooseAccountShareToDialogFragment import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog @@ -158,7 +155,6 @@ import org.apache.commons.lang3.builder.CompareToBuilder import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import retrofit2.HttpException -import java.io.File import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -192,9 +188,6 @@ class ConversationsListActivity : @Inject lateinit var networkMonitor: NetworkMonitor - @Inject - lateinit var chatViewModel: ChatViewModel - @Inject lateinit var contactsViewModel: ContactsViewModel @@ -487,54 +480,57 @@ class ConversationsListActivity : }.collect() } - lifecycleScope.launch { - chatViewModel.backgroundPlayUIFlow.onEach { msg -> - binding.composeViewForBackgroundPlay.apply { - // Dispose of the Composition when the view's LifecycleOwner is destroyed - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - msg?.let { - val duration = chatViewModel.mediaPlayerDuration - val position = chatViewModel.mediaPlayerPosition - val offset = position.toFloat() / duration - val imageURI = ApiUtils.getUrlForAvatar( - currentUser?.baseUrl, - msg.actorId, - true - ) - val conversationImageURI = ApiUtils.getUrlForConversationAvatar( - ApiUtils.API_V1, - currentUser?.baseUrl, - msg.token - ) - - if (duration > 0) { - BackgroundVoiceMessageCard( - msg.actorDisplayName!!, - duration - position, - offset, - imageURI, - conversationImageURI, - viewThemeUtils, - context - ) - .GetView({ isPaused -> - if (isPaused) { - chatViewModel.pauseMediaPlayer(false) - } else { - val filename = msg.selectedIndividualHashMap!!["name"] - val file = File(context.cacheDir, filename!!) - chatViewModel.startMediaPlayer(file.canonicalPath) - } - }) { - chatViewModel.stopMediaPlayer() - } - } - } - } - } - }.collect() - } + // TODO: playback of background voice messages must be reimplemented. It's not okay to use the chatViewModel + // in conversation list. Instead, reimplement playback with a foreground service?! + + // lifecycleScope.launch { + // chatViewModel.backgroundPlayUIFlow.onEach { msg -> + // binding.composeViewForBackgroundPlay.apply { + // // Dispose of the Composition when the view's LifecycleOwner is destroyed + // setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + // setContent { + // msg?.let { + // val duration = chatViewModel.mediaPlayerDuration + // val position = chatViewModel.mediaPlayerPosition + // val offset = position.toFloat() / duration + // val imageURI = ApiUtils.getUrlForAvatar( + // currentUser?.baseUrl, + // msg.actorId, + // true + // ) + // val conversationImageURI = ApiUtils.getUrlForConversationAvatar( + // ApiUtils.API_V1, + // currentUser?.baseUrl, + // msg.token + // ) + // + // if (duration > 0) { + // BackgroundVoiceMessageCard( + // msg.actorDisplayName!!, + // duration - position, + // offset, + // imageURI, + // conversationImageURI, + // viewThemeUtils, + // context + // ) + // .GetView({ isPaused -> + // if (isPaused) { + // chatViewModel.pauseMediaPlayer(false) + // } else { + // val filename = msg.selectedIndividualHashMap!!["name"] + // val file = File(context.cacheDir, filename!!) + // chatViewModel.startMediaPlayer(file.canonicalPath) + // } + // }) { + // chatViewModel.stopMediaPlayer() + // } + // } + // } + // } + // } + // }.collect() + // } } private fun handleNoteToSelfShortcut(noteToSelfAvailable: Boolean, noteToSelfToken: String) { @@ -1459,7 +1455,12 @@ class ConversationsListActivity : messageId = item.messageEntry.messageId!!, title = item.messageEntry.title ) - ContextChatView(context, contextChatViewModel) + ContextChatView( + user = currentUser!!, + context = context, + viewThemeUtils = viewThemeUtils, + contextViewModel = contextChatViewModel + ) } } } diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 2b5c699b814..e03c2d1a705 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -11,7 +11,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.nextcloud.talk.account.viewmodels.BrowserLoginActivityViewModel import com.nextcloud.talk.activities.CallViewModel -import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.chat.viewmodels.ScheduledMessagesViewModel import com.nextcloud.talk.chooseaccount.StatusViewModel import com.nextcloud.talk.contacts.ContactsViewModel @@ -48,6 +47,14 @@ class ViewModelFactory @Inject constructor( override fun create(modelClass: Class): T = viewModels[modelClass]?.get() as T } +class ViewModelFactoryWithParams(private val modelClass: Class, private val create: () -> T) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return create() as T + } +} + @Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) @Retention(AnnotationRetention.RUNTIME) @MapKey @@ -120,10 +127,10 @@ abstract class ViewModelModule { @ViewModelKey(ConversationsListViewModel::class) abstract fun conversationsListViewModel(viewModel: ConversationsListViewModel): ViewModel - @Binds - @IntoMap - @ViewModelKey(ChatViewModel::class) - abstract fun chatViewModel(viewModel: ChatViewModel): ViewModel + // @Binds + // @IntoMap + // @ViewModelKey(ChatViewModel::class) + // abstract fun chatViewModel(viewModel: ChatViewModel): ViewModel @Binds @IntoMap @@ -185,3 +192,11 @@ abstract class ViewModelModule { @ViewModelKey(ScheduledMessagesViewModel::class) abstract fun scheduledMessagesViewModel(viewModel: ScheduledMessagesViewModel): ViewModel } + +// @Module +// interface ChatViewModelAssistedModule { +// @Binds +// fun bindChatViewModelFactory( +// factory: ChatViewModel.Factory +// ): ChatViewModel.Factory +// } diff --git a/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatBlocksDao.kt b/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatBlocksDao.kt index 600f6a03d0d..b751b9a2fbf 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatBlocksDao.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatBlocksDao.kt @@ -81,4 +81,16 @@ interface ChatBlocksDao { """ ) fun deleteChatBlocksOlderThan(internalConversationId: String, messageId: Long) + + @Query( + """ + SELECT * + FROM ChatBlocks + WHERE internalConversationId = :internalConversationId + AND (threadId = :threadId OR (threadId IS NULL AND :threadId IS NULL)) + ORDER BY newestMessageId DESC + LIMIT 1 + """ + ) + fun getLatestChatBlock(internalConversationId: String, threadId: Long?): Flow } diff --git a/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt b/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt index 13f24a0211f..5d85eb687b5 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt @@ -18,16 +18,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) + // ORDER BY timestamp ASC, id ASC + // """ + + @Query( + """ + SELECT * + FROM ChatMessages + WHERE internalConversationId = :internalConversationId + AND isTemporary = 0 + AND (:threadId IS NULL OR threadId = :threadId) + AND id > :oldestMessageId + ORDER BY timestamp DESC, id DESC + """ + ) + fun getMessagesNewerThan( + internalConversationId: String, + threadId: Long?, + oldestMessageId: Long + ): Flow> + @Query( """ SELECT * FROM ChatMessages WHERE internalConversationId = :internalConversationId AND isTemporary = 0 + AND (:threadId IS NULL OR threadId = :threadId) ORDER BY timestamp DESC, id DESC """ ) - fun getMessagesForConversation(internalConversationId: String): Flow> + fun getMessagesForConversation(internalConversationId: String, threadId: Long?): Flow> @Query( """ @@ -89,6 +117,26 @@ interface ChatMessagesDao { ) fun getChatMessageForConversation(internalConversationId: String, messageId: Long): Flow + @Query( + """ + SELECT * + FROM ChatMessages + WHERE internalConversationId = :internalConversationId + AND id = :messageId + """ + ) + suspend fun getChatMessageOnce(internalConversationId: String, messageId: Long): ChatMessageEntity? + + @Query( + """ + SELECT * + FROM ChatMessages + WHERE internalConversationId = :internalConversationId + AND id = :messageId + """ + ) + fun getChatMessageForConversationNullable(internalConversationId: String, messageId: Long): Flow + @Query( """ SELECT * diff --git a/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt b/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt index 8e8822aec5e..59ae11ccbca 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 @@ -125,7 +125,7 @@ abstract class TalkDatabase : RoomDatabase() { return Room .databaseBuilder(context.applicationContext, TalkDatabase::class.java, dbName) // comment out openHelperFactory to view the database entries in Android Studio for debugging - .openHelperFactory(factory) + // .openHelperFactory(factory) .fallbackToDestructiveMigrationFrom(true, 18) .addMigrations(*MIGRATIONS) // * converts migrations to vararg .allowMainThreadQueries() diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index db996b99774..05dc46d3745 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,27 @@ 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 +76,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 +178,12 @@ class SharedItemsActivity : BaseActivity() { messageId = messageId!!, title = "" ) - ContextChatView(context, contextChatViewModel) + ContextChatView( + user = currentUserProviderOld.currentUser.blockingGet(), + context, + viewThemeUtils = viewThemeUtils, + contextChatViewModel + ) } } Log.d(TAG, "Should open something else") diff --git a/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt b/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt index 9bd2408abe9..5b5e4dbf242 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt +++ b/app/src/main/java/com/nextcloud/talk/signaling/ConversationMessageNotifier.kt @@ -6,6 +6,7 @@ */ package com.nextcloud.talk.signaling +import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.signaling.SignalingMessageReceiver.ConversationMessageListener internal class ConversationMessageNotifier { @@ -29,6 +30,13 @@ internal class ConversationMessageNotifier { } } + @Synchronized + fun notifyMessageReceived(chatMessage: ChatMessageJson) { + for (listener in ArrayList(conversationMessageListeners)) { + listener.onChatMessageReceived(chatMessage) + } + } + fun notifyStopTyping(userId: String?, sessionId: String?) { for (listener in ArrayList(conversationMessageListeners)) { listener.onStopTyping(userId, sessionId) diff --git a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt similarity index 64% rename from app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java rename to app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt index 397ba7b55ce..cd531eba1e3 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java +++ b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt @@ -4,265 +4,278 @@ * SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez * SPDX-License-Identifier: GPL-3.0-or-later */ -package com.nextcloud.talk.signaling; - -import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter; -import com.nextcloud.talk.models.json.converters.EnumParticipantTypeConverter; -import com.nextcloud.talk.models.json.participants.Participant; -import com.nextcloud.talk.models.json.signaling.NCIceCandidate; -import com.nextcloud.talk.models.json.signaling.NCMessagePayload; -import com.nextcloud.talk.models.json.signaling.NCSignalingMessage; -import com.nextcloud.talk.models.json.websocket.CallWebSocketMessage; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; +package com.nextcloud.talk.signaling + +import com.bluelinelabs.logansquare.LoganSquare +import com.nextcloud.talk.models.json.chat.ChatMessageJson +import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter +import com.nextcloud.talk.models.json.converters.EnumParticipantTypeConverter +import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.models.json.signaling.NCSignalingMessage +import com.nextcloud.talk.models.json.websocket.CallWebSocketMessage +import org.json.JSONObject +import kotlin.Any +import kotlin.Int +import kotlin.Long +import kotlin.RuntimeException +import kotlin.String +import kotlin.toString /** * Hub to register listeners for signaling messages of different kinds. - *

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ * + * * Maps from internal and external signaling server messages can be used. Nevertheless, besides the differences * between the messages and the optional properties, it is expected that the message is correct and the given data * is parseable. Broken messages (for example, a string instead of an integer for "inCall" or a missing @@ -487,70 +525,73 @@ protected void processUsersInRoom(List> users) { * @param participantMap the map with the participant data * @return the Participant */ - private Participant getParticipantFromMessageMap(Map participantMap) { - Participant participant = new Participant(); + private fun getParticipantFromMessageMap(participantMap: Map): Participant { + val participant = Participant() - participant.setInCall(Long.parseLong(participantMap.get("inCall").toString())); - participant.setLastPing(Long.parseLong(participantMap.get("lastPing").toString())); - participant.setSessionId(participantMap.get("sessionId").toString()); + participant.inCall = participantMap["inCall"].toString().toLong() + participant.lastPing = participantMap["lastPing"].toString().toLong() + participant.sessionId = participantMap["sessionId"].toString() - if (participantMap.get("userId") != null && !participantMap.get("userId").toString().isEmpty()) { - participant.setUserId(participantMap.get("userId").toString()); + if (participantMap["userId"] != null && !participantMap["userId"].toString().isEmpty()) { + participant.userId = participantMap["userId"].toString() } - if (participantMap.get("internal") != null && Boolean.parseBoolean(participantMap.get("internal").toString())) { - participant.setInternal(Boolean.TRUE); + if (participantMap["internal"] != null && participantMap["internal"].toString().toBoolean()) { + participant.internal = true } - if (participantMap.get("actorType") != null && !participantMap.get("actorType").toString().isEmpty()) { - participant.setActorType(enumActorTypeConverter.getFromString(participantMap.get("actorType").toString())); + if (participantMap["actorType"] != null && !participantMap["actorType"].toString().isEmpty()) { + participant.actorType = enumActorTypeConverter.getFromString(participantMap["actorType"].toString()) } - if (participantMap.get("actorId") != null && !participantMap.get("actorId").toString().isEmpty()) { - participant.setActorId(participantMap.get("actorId").toString()); + if (participantMap["actorId"] != null && !participantMap["actorId"].toString().isEmpty()) { + participant.actorId = participantMap["actorId"].toString() } // Only in external signaling messages - if (participantMap.get("participantType") != null) { - int participantTypeInt = Integer.parseInt(participantMap.get("participantType").toString()); + if (participantMap["participantType"] != null) { + val participantTypeInt = participantMap["participantType"].toString().toInt() - EnumParticipantTypeConverter converter = new EnumParticipantTypeConverter(); - participant.setType(converter.getFromInt(participantTypeInt)); + val converter = EnumParticipantTypeConverter() + participant.type = converter.getFromInt(participantTypeInt) } - return participant; + return participant } - protected void processCallWebSocketMessage(CallWebSocketMessage callWebSocketMessage) { - - NCSignalingMessage signalingMessage = callWebSocketMessage.getNcSignalingMessage(); + protected fun processCallWebSocketMessage(callWebSocketMessage: CallWebSocketMessage) { + val signalingMessage = callWebSocketMessage.ncSignalingMessage - if (callWebSocketMessage.getSenderWebSocketMessage() != null && signalingMessage != null) { - String type = signalingMessage.getType(); + if (callWebSocketMessage.senderWebSocketMessage != null && signalingMessage != null) { + val type = signalingMessage.type - String userId = callWebSocketMessage.getSenderWebSocketMessage().getUserid(); - String sessionId = signalingMessage.getFrom(); + val userId = callWebSocketMessage.senderWebSocketMessage!!.userid + val sessionId = signalingMessage.from - if ("startedTyping".equals(type)) { - conversationMessageNotifier.notifyStartTyping(userId, sessionId); + if ("startedTyping" == type) { + conversationMessageNotifier.notifyStartTyping(userId, sessionId) } - if ("stoppedTyping".equals(type)) { - conversationMessageNotifier.notifyStopTyping(userId, sessionId); + if ("stoppedTyping" == type) { + conversationMessageNotifier.notifyStopTyping(userId, sessionId) } } } - protected void processSignalingMessage(NCSignalingMessage signalingMessage) { + fun processSignalingMessage(signalingMessage: NCSignalingMessage?) { + if (signalingMessage == null) { + return + } + // Note that in the internal signaling server message "data" is the String representation of a JSON // object, although it is already decoded when used here. - String type = signalingMessage.getType(); + val type = signalingMessage.type - String sessionId = signalingMessage.getFrom(); - String roomType = signalingMessage.getRoomType(); + val sessionId = signalingMessage.from + val roomType = signalingMessage.roomType - if ("raiseHand".equals(type)) { + if ("raiseHand" == type) { // Message schema (external signaling server): // { // "type": "message", @@ -588,26 +629,16 @@ protected void processSignalingMessage(NCSignalingMessage signalingMessage) { // }, // } - NCMessagePayload payload = signalingMessage.getPayload(); - if (payload == null) { - // Broken message, this should not happen. - return; - } - - Boolean state = payload.getState(); - Long timestamp = payload.getTimestamp(); + val payload = signalingMessage.payload ?: return + val state = payload.state ?: return + val timestamp = payload.timestamp ?: return - if (state == null || timestamp == null) { - // Broken message, this should not happen. - return; - } + callParticipantMessageNotifier.notifyRaiseHand(sessionId, state, timestamp) - callParticipantMessageNotifier.notifyRaiseHand(sessionId, state, timestamp); - - return; + return } - if ("reaction".equals(type)) { + if ("reaction" == type) { // Message schema (external signaling server): // { // "type": "message", @@ -641,27 +672,19 @@ protected void processSignalingMessage(NCSignalingMessage signalingMessage) { // }, // } - NCMessagePayload payload = signalingMessage.getPayload(); - if (payload == null) { - // Broken message, this should not happen. - return; - } + val payload = signalingMessage.payload ?: return - String reaction = payload.getReaction(); - if (reaction == null) { - // Broken message, this should not happen. - return; - } + val reaction = payload.reaction ?: return - callParticipantMessageNotifier.notifyReaction(sessionId, reaction); + callParticipantMessageNotifier.notifyReaction(sessionId, reaction) - return; + return } // "unshareScreen" messages are directly sent to the screen peer connection when the internal signaling // server is used, and to the room when the external signaling server is used. However, the (relevant) data // of the received message ("from" and "type") is the same in both cases. - if ("unshareScreen".equals(type)) { + if ("unshareScreen" == type) { // Message schema (external signaling server): // { // "type": "message", @@ -690,12 +713,12 @@ protected void processSignalingMessage(NCSignalingMessage signalingMessage) { // }, // } - callParticipantMessageNotifier.notifyUnshareScreen(sessionId); + callParticipantMessageNotifier.notifyUnshareScreen(sessionId) - return; + return } - if ("offer".equals(type)) { + if ("offer" == type) { // Message schema (external signaling server): // { // "type": "message", @@ -734,43 +757,35 @@ protected void processSignalingMessage(NCSignalingMessage signalingMessage) { // }, // } - NCMessagePayload payload = signalingMessage.getPayload(); - if (payload == null) { - // Broken message, this should not happen. - return; - } + val payload = signalingMessage.payload ?: return - String sdp = payload.getSdp(); - String nick = payload.getNick(); + val sdp = payload.sdp + val nick = payload.nick // If "processSignalingMessage" is called with two offers from two different threads it is possible, // although extremely unlikely, that the WebRtcMessageListeners for the second offer are notified before the // WebRtcMessageListeners for the first offer. This should not be a problem, though, so for simplicity // the statements are not synchronized. - offerMessageNotifier.notifyOffer(sessionId, roomType, sdp, nick); - webRtcMessageNotifier.notifyOffer(sessionId, roomType, sdp, nick); + offerMessageNotifier.notifyOffer(sessionId, roomType, sdp, nick) + webRtcMessageNotifier.notifyOffer(sessionId, roomType, sdp, nick) - return; + return } - if ("answer".equals(type)) { + if ("answer" == type) { // Message schema: same as offers, but with type "answer". - NCMessagePayload payload = signalingMessage.getPayload(); - if (payload == null) { - // Broken message, this should not happen. - return; - } + val payload = signalingMessage.payload ?: return - String sdp = payload.getSdp(); - String nick = payload.getNick(); + val sdp = payload.sdp + val nick = payload.nick - webRtcMessageNotifier.notifyAnswer(sessionId, roomType, sdp, nick); + webRtcMessageNotifier.notifyAnswer(sessionId, roomType, sdp, nick) - return; + return } - if ("candidate".equals(type)) { + if ("candidate" == type) { // Message schema (external signaling server): // { // "type": "message", @@ -814,31 +829,25 @@ protected void processSignalingMessage(NCSignalingMessage signalingMessage) { // }, // } - NCMessagePayload payload = signalingMessage.getPayload(); - if (payload == null) { - // Broken message, this should not happen. - return; - } + val payload = signalingMessage.payload ?: return - NCIceCandidate ncIceCandidate = payload.getIceCandidate(); - if (ncIceCandidate == null) { - // Broken message, this should not happen. - return; - } + val ncIceCandidate = payload.iceCandidate ?: return - webRtcMessageNotifier.notifyCandidate(sessionId, - roomType, - ncIceCandidate.getSdpMid(), - ncIceCandidate.getSdpMLineIndex(), - ncIceCandidate.getCandidate()); + webRtcMessageNotifier.notifyCandidate( + sessionId, + roomType, + ncIceCandidate.sdpMid, + ncIceCandidate.sdpMLineIndex, + ncIceCandidate.candidate + ) - return; + return } - if ("endOfCandidates".equals(type)) { - webRtcMessageNotifier.notifyEndOfCandidates(sessionId, roomType); + if ("endOfCandidates" == type) { + webRtcMessageNotifier.notifyEndOfCandidates(sessionId, roomType) - return; + return } } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt b/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt index e0aa7c70c64..e3ce4c1c901 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt @@ -7,1077 +7,119 @@ package com.nextcloud.talk.ui -import android.content.Context -import android.util.Log -import android.view.View.TEXT_ALIGNMENT_VIEW_START -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import android.widget.LinearLayout -import androidx.compose.animation.animateColor -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.graphics.ColorUtils -import androidx.emoji2.widget.EmojiTextView -import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow -import autodagger.AutoInjector -import coil.compose.AsyncImage -import com.elyeproj.loaderviewlibrary.LoaderImageView -import com.elyeproj.loaderviewlibrary.LoaderTextView -import com.nextcloud.talk.R -import com.nextcloud.talk.adapters.messages.PreviewMessageViewHolder.Companion.KEY_MIMETYPE -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.chat.data.model.ChatMessage -import com.nextcloud.talk.chat.viewmodels.ChatViewModel -import com.nextcloud.talk.contacts.ContactsViewModel -import com.nextcloud.talk.contacts.load -import com.nextcloud.talk.contacts.loadImage -import com.nextcloud.talk.data.database.mappers.asModel -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.models.json.chat.ChatMessageJson -import com.nextcloud.talk.models.json.chat.ReadStatus -import com.nextcloud.talk.models.json.opengraph.Reference -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.users.UserManager -import com.nextcloud.talk.utils.DateUtils -import com.nextcloud.talk.utils.DisplayUtils -import com.nextcloud.talk.utils.DrawableUtils.getDrawableResourceIdForMimeType -import com.nextcloud.talk.utils.message.MessageUtils -import com.nextcloud.talk.utils.preview.ComposePreviewUtils -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import org.osmdroid.config.Configuration -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.Marker -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import javax.inject.Inject -import kotlin.random.Random - -@Suppress("FunctionNaming", "TooManyFunctions", "LongMethod", "StaticFieldLeak", "LargeClass") -class ComposeChatAdapter( - private var messagesJson: List? = null, - private var messageId: String? = null, - private var threadId: String? = null, - private val utils: ComposePreviewUtils? = null -) { - - interface PreviewAble { - val viewThemeUtils: ViewThemeUtils - val messageUtils: MessageUtils - val contactsViewModel: ContactsViewModel - val chatViewModel: ChatViewModel - val context: Context - val userManager: UserManager - } - - @AutoInjector(NextcloudTalkApplication::class) - inner class ComposeChatAdapterViewModel : - ViewModel(), - PreviewAble { - - @Inject - override lateinit var viewThemeUtils: ViewThemeUtils - - @Inject - override lateinit var messageUtils: MessageUtils - - @Inject - override lateinit var contactsViewModel: ContactsViewModel - - @Inject - override lateinit var chatViewModel: ChatViewModel - - @Inject - override lateinit var context: Context - - @Inject - override lateinit var userManager: UserManager - - init { - sharedApplication?.componentApplication?.inject(this) - } - } - - class ComposeChatAdapterPreviewViewModel( - override val viewThemeUtils: ViewThemeUtils, - override val messageUtils: MessageUtils, - override val contactsViewModel: ContactsViewModel, - override val chatViewModel: ChatViewModel, - override val context: Context, - override val userManager: UserManager - ) : ViewModel(), - PreviewAble - - companion object { - val TAG: String = ComposeChatAdapter::class.java.simpleName - private val REGULAR_TEXT_SIZE = 16.sp - private val TIME_TEXT_SIZE = 12.sp - private val AUTHOR_TEXT_SIZE = 12.sp - private const val LONG_1000 = 1000 - private const val SCROLL_DELAY = 20L - private const val QUOTE_SHAPE_OFFSET = 6 - private const val LINE_SPACING = 1.2f - private const val CAPTION_WEIGHT = 0.8f - private const val DEFAULT_WAVE_SIZE = 50 - private const val MAP_ZOOM = 15.0 - private const val INT_8 = 8 - private const val INT_128 = 128 - private const val ANIMATION_DURATION = 2500L - private const val ANIMATED_BLINK = 500 - private const val FLOAT_06 = 0.6f - private const val HALF_OPACITY = 127 - private const val MESSAGE_LENGTH_THRESHOLD = 25 - } - - private var incomingShape: RoundedCornerShape = RoundedCornerShape(2.dp, 20.dp, 20.dp, 20.dp) - private var outgoingShape: RoundedCornerShape = RoundedCornerShape(20.dp, 2.dp, 20.dp, 20.dp) - - val viewModel: PreviewAble = - if (utils != null) { - ComposeChatAdapterPreviewViewModel( - utils.viewThemeUtils, - utils.messageUtils, - utils.contactsViewModel, - utils.chatViewModel, - utils.context, - utils.userManager - ) - } else { - ComposeChatAdapterViewModel() - } - - val items = mutableStateListOf() - val currentUser: User = viewModel.userManager.currentUser.blockingGet() - val colorScheme = viewModel.viewThemeUtils.getColorScheme(viewModel.context) - val highEmphasisColorInt = if (DisplayUtils.isAppThemeDarkMode(viewModel.context)) { - Color.White.toArgb() - } else { - Color.Black.toArgb() - } - val highEmphasisColor = Color(highEmphasisColorInt) - - fun addMessages(messages: MutableList, append: Boolean) { - if (messages.isEmpty()) return - - val processedMessages = messages.toMutableList() - if (items.isNotEmpty()) { - if (append) { - processedMessages.add(items.first()) - } else { - processedMessages.add(items.last()) - } - } - - if (append) items.addAll(processedMessages) else items.addAll(0, processedMessages) - } - - @Composable - fun GetComposableForMessage(message: ChatMessage, isBlinkingState: MutableState = mutableStateOf(false)) { - message.activeUser = currentUser - when (val type = message.getCalculateMessageType()) { - ChatMessage.MessageType.SYSTEM_MESSAGE -> { - if (!message.shouldFilter()) { - SystemMessage(message) - } - } - - ChatMessage.MessageType.VOICE_MESSAGE -> { - VoiceMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE -> { - ImageMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.SINGLE_NC_GEOLOCATION_MESSAGE -> { - GeolocationMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.POLL_MESSAGE -> { - PollMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.DECK_CARD -> { - DeckMessage(message, isBlinkingState) - } - - ChatMessage.MessageType.REGULAR_TEXT_MESSAGE -> { - if (message.isLinkPreview()) { - LinkMessage(message, isBlinkingState) - } else { - TextMessage(message, isBlinkingState) - } - } - - else -> { - Log.d(TAG, "Unknown message type: $type") - } - } - } - - @OptIn(ExperimentalFoundationApi::class) - @Composable - fun GetView() { - val listState = rememberLazyListState() - val isBlinkingState = remember { mutableStateOf(true) } - - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - state = listState, - modifier = Modifier.padding(16.dp) - ) { - stickyHeader { - if (items.size == 0) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize() - ) { - ShimmerGroup() - } - } else { - val timestamp = items[listState.firstVisibleItemIndex].timestamp - val dateString = formatTime(timestamp * LONG_1000) - val color = highEmphasisColor - val backgroundColor = - LocalResources.current.getColor(R.color.bg_message_list_incoming_bubble, null) - Row( - horizontalArrangement = Arrangement.Absolute.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - dateString, - fontSize = AUTHOR_TEXT_SIZE, - color = color, - modifier = Modifier - .padding(8.dp) - .shadow( - 16.dp, - spotColor = colorScheme.primary, - ambientColor = colorScheme.primary - ) - .background(color = Color(backgroundColor), shape = RoundedCornerShape(8.dp)) - .padding(8.dp) - ) - Spacer(modifier = Modifier.weight(1f)) - } - } - } - - items(items) { message -> - message.incoming = message.actorId != currentUser.userId - GetComposableForMessage(message, isBlinkingState) - } - } - - if (messageId != null && items.size > 0) { - LaunchedEffect(Dispatchers.Main) { - delay(SCROLL_DELAY) - val pos = searchMessages(messageId!!) - if (pos > 0) { - listState.scrollToItem(pos) - } - delay(ANIMATION_DURATION) - isBlinkingState.value = false - } - } - } - - private fun ChatMessage.shouldFilter(): Boolean = - this.isReaction() || - this.isPollVotedMessage() || - this.isEditMessage() || - this.isInfoMessageAboutDeletion() || - this.isThreadCreatedMessage() - - private fun ChatMessage.isInfoMessageAboutDeletion(): Boolean = - this.parentMessageId != null && - this.systemMessageType == ChatMessage.SystemMessageType.MESSAGE_DELETED - - private fun ChatMessage.isPollVotedMessage(): Boolean = - this.systemMessageType == ChatMessage.SystemMessageType.POLL_VOTED - - private fun ChatMessage.isEditMessage(): Boolean = - this.systemMessageType == ChatMessage.SystemMessageType.MESSAGE_EDITED - - private fun ChatMessage.isThreadCreatedMessage(): Boolean = - this.systemMessageType == ChatMessage.SystemMessageType.THREAD_CREATED - - private fun ChatMessage.isReaction(): Boolean = - systemMessageType == ChatMessage.SystemMessageType.REACTION || - systemMessageType == ChatMessage.SystemMessageType.REACTION_DELETED || - systemMessageType == ChatMessage.SystemMessageType.REACTION_REVOKED - - private fun formatTime(timestampMillis: Long): String { - val instant = Instant.ofEpochMilli(timestampMillis) - val dateTime = instant.atZone(ZoneId.systemDefault()).toLocalDate() - val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy") - return dateTime.format(formatter) - } - - private fun searchMessages(searchId: String): Int { - items.forEachIndexed { index, message -> - if (message.id == searchId) return index - } - return -1 - } - - @Composable - private fun CommonMessageQuote(context: Context, message: ChatMessage) { - val color = colorResource(R.color.high_emphasis_text) - Row( - modifier = Modifier - .drawWithCache { - onDrawWithContent { - drawLine( - color = color, - start = Offset(0f, this.size.height / QUOTE_SHAPE_OFFSET), - end = Offset(0f, this.size.height - (this.size.height / QUOTE_SHAPE_OFFSET)), - strokeWidth = 4f, - cap = StrokeCap.Round - ) - - drawContent() - } - } - .padding(8.dp) - ) { - Column { - Text(message.actorDisplayName!!, fontSize = AUTHOR_TEXT_SIZE) - val imageUri = message.imageUrl - if (imageUri != null) { - val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image - val loadedImage = loadImage(imageUri, context, errorPlaceholderImage) - AsyncImage( - model = loadedImage, - contentDescription = stringResource(R.string.nc_sent_an_image), - modifier = Modifier - .padding(8.dp) - .fillMaxHeight() - ) - } - EnrichedText(message) - } - } - } - - @Composable - private fun CommonMessageBody( - message: ChatMessage, - includePadding: Boolean = true, - playAnimation: Boolean = false, - content: @Composable () -> Unit - ) { - fun shouldShowTimeNextToContent(message: ChatMessage): Boolean { - val containsLinebreak = message.message?.contains("\n") ?: false || - message.message?.contains("\r") ?: false - - return ((message.message?.length ?: 0) < MESSAGE_LENGTH_THRESHOLD) && - !isFirstMessageOfThreadInNormalChat(message) && - message.messageParameters.isNullOrEmpty() && - !containsLinebreak - } - - val incoming = message.incoming - val color = if (incoming) { - if (message.isDeleted) { - getColorFromTheme(LocalContext.current, R.color.bg_message_list_incoming_bubble_deleted) - } else { - getColorFromTheme(LocalContext.current, R.color.bg_message_list_incoming_bubble) - } - } else { - val outgoingBubbleColor = viewModel.viewThemeUtils.talk - .getOutgoingMessageBubbleColor(LocalContext.current, message.isDeleted, false) - - if (message.isDeleted) { - ColorUtils.setAlphaComponent(outgoingBubbleColor, HALF_OPACITY) - } else { - outgoingBubbleColor - } - } - - val shape = if (incoming) incomingShape else outgoingShape - - val rowModifier = if (message.id == messageId && playAnimation) { - Modifier.withCustomAnimation(incoming) - } else { - Modifier - } - - Row( - modifier = rowModifier.fillMaxWidth(), - horizontalArrangement = if (incoming) Arrangement.Start else Arrangement.End - ) { - if (incoming) { - val imageUri = message.actorId?.let { viewModel.contactsViewModel.getImageUri(it, true) } - val errorPlaceholderImage: Int = R.drawable.account_circle_96dp - val loadedImage = loadImage(imageUri, LocalContext.current, errorPlaceholderImage) - AsyncImage( - model = loadedImage, - contentDescription = stringResource(R.string.user_avatar), - modifier = Modifier - .size(48.dp) - .align(Alignment.CenterVertically) - .padding(end = 8.dp) - ) - } else { - Spacer(Modifier.width(8.dp)) - } - - Surface( - modifier = Modifier - .defaultMinSize(60.dp, 40.dp) - .widthIn(60.dp, 280.dp) - .heightIn(40.dp, 450.dp), - color = Color(color), - shape = shape - ) { - val modifier = if (includePadding) { - Modifier.padding(16.dp, 4.dp, 16.dp, 4.dp) - } else { - Modifier - } - - Column(modifier = modifier) { - if (messagesJson != null && - message.parentMessageId != null && - !message.isDeleted && - message.parentMessageId.toString() != threadId - ) { - messagesJson!! - .find { it.parentMessage?.id == message.parentMessageId } - ?.parentMessage!!.asModel() - .let { CommonMessageQuote(LocalContext.current, it) } - } - - if (incoming) { - Text( - message.actorDisplayName.toString(), - fontSize = AUTHOR_TEXT_SIZE, - color = highEmphasisColor - ) - } - - ThreadTitle(message) - - if (shouldShowTimeNextToContent(message)) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - content() - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 6.dp, start = 8.dp) - ) { - TimeDisplay(message) - ReadStatus(message) - } - } - } else { - content() - Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically - ) { - TimeDisplay(message) - ReadStatus(message) - } - } - } - } - } - } - - private fun getColorFromTheme(context: Context, resourceId: Int): Int { - val isDarkMode = DisplayUtils.isAppThemeDarkMode(context) - val nightConfig = android.content.res.Configuration() - nightConfig.uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES - val nightContext = context.createConfigurationContext(nightConfig) - - return if (isDarkMode) { - nightContext.getColor(resourceId) - } else { - context.getColor(resourceId) - } - } - - @Composable - private fun TimeDisplay(message: ChatMessage) { - val timeString = DateUtils(LocalContext.current) - .getLocalTimeStringFromTimestamp(message.timestamp) - Text( - timeString, - fontSize = TIME_TEXT_SIZE, - textAlign = TextAlign.Center, - color = highEmphasisColor - ) - } - - @Composable - private fun ReadStatus(message: ChatMessage) { - if (message.readStatus == ReadStatus.NONE) { - val read = painterResource(R.drawable.ic_check_all) - Icon( - read, - "", - modifier = Modifier - .padding(start = 4.dp) - .size(16.dp), - tint = highEmphasisColor - ) - } - } - - @Composable - private fun ThreadTitle(message: ChatMessage) { - if (isFirstMessageOfThreadInNormalChat(message)) { - Row { - val read = painterResource(R.drawable.outline_forum_24) - Icon( - read, - "", - modifier = Modifier - .padding(end = 6.dp) - .size(18.dp) - .align(Alignment.CenterVertically) - ) - Text( - text = message.threadTitle ?: "", - fontSize = REGULAR_TEXT_SIZE, - fontWeight = FontWeight.SemiBold - ) - } - } - } - - fun isFirstMessageOfThreadInNormalChat(message: ChatMessage): Boolean = threadId == null && message.isThread - - @Composable - private fun Modifier.withCustomAnimation(incoming: Boolean): Modifier { - val infiniteTransition = rememberInfiniteTransition() - val borderColor by infiniteTransition.animateColor( - initialValue = colorScheme.primary, - targetValue = colorScheme.background, - animationSpec = infiniteRepeatable( - animation = tween(ANIMATED_BLINK, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ) - ) - - return this.border( - width = 4.dp, - color = borderColor, - shape = if (incoming) incomingShape else outgoingShape - ) - } - - @Composable - private fun ShimmerGroup() { - Shimmer() - Shimmer(true) - Shimmer() - Shimmer(true) - Shimmer(true) - Shimmer() - Shimmer(true) - } - - @Composable - private fun Shimmer(outgoing: Boolean = false) { - Row(modifier = Modifier.padding(top = 16.dp)) { - if (!outgoing) { - ShimmerImage(this) - } - - val v1 by remember { mutableIntStateOf((INT_8..INT_128).random()) } - val v2 by remember { mutableIntStateOf((INT_8..INT_128).random()) } - val v3 by remember { mutableIntStateOf((INT_8..INT_128).random()) } - - Column { - ShimmerText(this, v1, outgoing) - ShimmerText(this, v2, outgoing) - ShimmerText(this, v3, outgoing) - } - } - } - - @Composable - private fun ShimmerImage(rowScope: RowScope) { - rowScope.apply { - AndroidView( - factory = { ctx -> - LoaderImageView(ctx).apply { - layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) - val color = resources.getColor(R.color.nc_shimmer_default_color, null) - setBackgroundColor(color) - } - }, - modifier = Modifier - .clip(CircleShape) - .size(40.dp) - .align(Alignment.Top) - ) - } - } - - @Composable - private fun ShimmerText(columnScope: ColumnScope, margin: Int, outgoing: Boolean = false) { - columnScope.apply { - AndroidView( - factory = { ctx -> - LoaderTextView(ctx).apply { - layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - val color = if (outgoing) { - colorScheme.primary.toArgb() - } else { - resources.getColor(R.color.nc_shimmer_default_color, null) - } - - setBackgroundColor(color) - } - }, - modifier = Modifier.padding( - top = 6.dp, - end = if (!outgoing) margin.dp else 8.dp, - start = if (outgoing) margin.dp else 8.dp - ) - ) - } - } - - @Composable - private fun EnrichedText(message: ChatMessage) { - AndroidView(factory = { ctx -> - val incoming = message.actorId != currentUser.userId - var processedMessageText = viewModel.messageUtils.enrichChatMessageText( - ctx, - message, - incoming, - viewModel.viewThemeUtils - ) - - processedMessageText = viewModel.messageUtils.processMessageParameters( - ctx, - viewModel.viewThemeUtils, - processedMessageText!!, - message, - null - ) - - EmojiTextView(ctx).apply { - layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) - setLineSpacing(0F, LINE_SPACING) - textAlignment = TEXT_ALIGNMENT_VIEW_START - text = processedMessageText - setPadding(0, INT_8, 0, 0) - } - }, modifier = Modifier) - } - - @Composable - private fun TextMessage(message: ChatMessage, state: MutableState) { - CommonMessageBody(message, playAnimation = state.value) { - EnrichedText(message) - } - } - - @Composable - fun SystemMessage(message: ChatMessage) { - val similarMessages = sharedApplication!!.resources.getQuantityString( - R.plurals.see_similar_system_messages, - message.expandableChildrenAmount, - message.expandableChildrenAmount - ) - Column(horizontalAlignment = Alignment.CenterHorizontally) { - val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) - Row(horizontalArrangement = Arrangement.Absolute.Center, verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Modifier.weight(1f)) - Text( - message.text, - fontSize = AUTHOR_TEXT_SIZE, - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(FLOAT_06) - ) - Text( - timeString, - fontSize = TIME_TEXT_SIZE, - textAlign = TextAlign.End, - modifier = Modifier.align(Alignment.CenterVertically) - ) - Spacer(modifier = Modifier.weight(1f)) - } - - if (message.expandableChildrenAmount > 0) { - TextButtonNoStyling(similarMessages) { - // NOTE: Read only for now - } - } - } - } - - @Composable - private fun TextButtonNoStyling(text: String, onClick: () -> Unit) { - TextButton(onClick = onClick) { - Text( - text, - fontSize = AUTHOR_TEXT_SIZE, - color = highEmphasisColor - ) - } - } - - @Composable - private fun ImageMessage(message: ChatMessage, state: MutableState) { - val hasCaption = (message.message != "{file}") - val incoming = message.actorId != currentUser.userId - val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) - CommonMessageBody(message, includePadding = false, playAnimation = state.value) { - Column { - message.activeUser = currentUser - val imageUri = message.imageUrl - val mimetype = message.selectedIndividualHashMap!![KEY_MIMETYPE] - val drawableResourceId = getDrawableResourceIdForMimeType(mimetype) - val loadedImage = load(imageUri, LocalContext.current, drawableResourceId) - - AsyncImage( - model = loadedImage, - contentDescription = stringResource(R.string.nc_sent_an_image), - modifier = Modifier - .fillMaxWidth(), - contentScale = ContentScale.FillWidth - ) - - if (hasCaption) { - Text( - message.text, - fontSize = 12.sp, - modifier = Modifier - .widthIn(20.dp, 140.dp) - .padding(8.dp) - ) - } - } - } - - if (!hasCaption) { - Row(modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { - if (!incoming) { - Spacer(Modifier.weight(1f)) - } else { - Spacer(Modifier.size(width = 56.dp, 0.dp)) // To account for avatar size - } - Text(message.text, fontSize = 12.sp) - Text( - timeString, - fontSize = TIME_TEXT_SIZE, - textAlign = TextAlign.End, - modifier = Modifier - .align(Alignment.CenterVertically) - .padding() - .padding(start = 4.dp) - ) - if (message.readStatus == ReadStatus.NONE) { - val read = painterResource(R.drawable.ic_check_all) - Icon( - read, - "", - modifier = Modifier - .padding(start = 4.dp) - .size(16.dp) - .align(Alignment.CenterVertically) - ) - } - } - } - } - - @Composable - private fun VoiceMessage(message: ChatMessage, state: MutableState) { - CommonMessageBody(message, playAnimation = state.value) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Filled.PlayArrow, - contentDescription = "play", - modifier = Modifier.size(24.dp) - ) - - AndroidView( - factory = { ctx -> - WaveformSeekBar(ctx).apply { - setWaveData(FloatArray(DEFAULT_WAVE_SIZE) { Random.nextFloat() }) // READ ONLY for now - setColors( - colorScheme.inversePrimary.toArgb(), - colorScheme.onPrimaryContainer.toArgb() - ) - } - }, - modifier = Modifier - .width(180.dp) - .height(80.dp) - ) - } - } - } - - @Composable - private fun GeolocationMessage(message: ChatMessage, state: MutableState) { - CommonMessageBody(message, playAnimation = state.value) { - Column { - if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { - for (key in message.messageParameters!!.keys) { - val individualHashMap: Map = message.messageParameters!![key]!! - if (individualHashMap["type"] == "geo-location") { - val lat = individualHashMap["latitude"] - val lng = individualHashMap["longitude"] - - if (lat != null && lng != null) { - val latitude = lat.toDouble() - val longitude = lng.toDouble() - OpenStreetMap(latitude, longitude) - } - } - } - } - } - } - } - - @Composable - private fun OpenStreetMap(latitude: Double, longitude: Double) { - AndroidView( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(), - factory = { context -> - Configuration.getInstance().userAgentValue = context.packageName - MapView(context).apply { - setTileSource(TileSourceFactory.MAPNIK) - setMultiTouchControls(true) - - val geoPoint = GeoPoint(latitude, longitude) - controller.setCenter(geoPoint) - controller.setZoom(MAP_ZOOM) - - val marker = Marker(this) - marker.position = geoPoint - marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - marker.title = "Location" - overlays.add(marker) - - invalidate() - } - }, - update = { mapView -> - val geoPoint = GeoPoint(latitude, longitude) - mapView.controller.setCenter(geoPoint) - - val marker = mapView.overlays.find { it is Marker } as? Marker - marker?.position = geoPoint - mapView.invalidate() - } - ) - } - - @Composable - private fun LinkMessage(message: ChatMessage, state: MutableState) { - val color = colorResource(R.color.high_emphasis_text) - viewModel.chatViewModel.getOpenGraph( - currentUser.getCredentials(), - currentUser.baseUrl!!, - message.extractedUrlToPreview!! - ) - CommonMessageBody(message, playAnimation = state.value) { - EnrichedText(message) - Row( - modifier = Modifier - .drawWithCache { - onDrawWithContent { - drawLine( - color = color, - start = Offset.Zero, - end = Offset(0f, this.size.height), - strokeWidth = 4f, - cap = StrokeCap.Round - ) - - drawContent() - } - } - .padding(8.dp) - .padding(4.dp) - ) { - Column { - val graphObject = viewModel.chatViewModel.getOpenGraph.asFlow().collectAsState( - Reference( - // Dummy class - ) - ).value.openGraphObject - graphObject?.let { - Text(it.name, fontSize = REGULAR_TEXT_SIZE, fontWeight = FontWeight.Bold) - it.description?.let { Text(it, fontSize = AUTHOR_TEXT_SIZE) } - it.link?.let { Text(it, fontSize = TIME_TEXT_SIZE) } - it.thumb?.let { - val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image - val loadedImage = load(it, LocalContext.current, errorPlaceholderImage) - AsyncImage( - model = loadedImage, - contentDescription = stringResource(R.string.nc_sent_an_image), - modifier = Modifier - .height(120.dp) - ) - } - } - } - } - } - } - - @Composable - private fun PollMessage(message: ChatMessage, state: MutableState) { - CommonMessageBody(message, playAnimation = state.value) { - Column { - if (message.messageParameters != null && message.messageParameters!!.size > 0) { - for (key in message.messageParameters!!.keys) { - val individualHashMap: Map = message.messageParameters!![key]!! - if (individualHashMap["type"] == "talk-poll") { - // val pollId = individualHashMap["id"] - val pollName = individualHashMap["name"].toString() - Row(modifier = Modifier.padding(start = 8.dp)) { - Icon(painterResource(R.drawable.ic_baseline_bar_chart_24), "") - Text(pollName, fontSize = AUTHOR_TEXT_SIZE, fontWeight = FontWeight.Bold) - } - - TextButtonNoStyling(stringResource(R.string.message_poll_tap_to_open)) { - // NOTE: read only for now - } - } - } - } - } - } - } - - @Composable - private fun DeckMessage(message: ChatMessage, state: MutableState) { - CommonMessageBody(message, playAnimation = state.value) { - Column { - if (message.messageParameters != null && message.messageParameters!!.size > 0) { - for (key in message.messageParameters!!.keys) { - val individualHashMap: Map = message.messageParameters!![key]!! - if (individualHashMap["type"] == "deck-card") { - val cardName = individualHashMap["name"] - val stackName = individualHashMap["stackname"] - val boardName = individualHashMap["boardname"] - // val cardLink = individualHashMap["link"] - - if (cardName?.isNotEmpty() == true) { - val cardDescription = String.format( - LocalContext.current.resources.getString(R.string.deck_card_description), - stackName, - boardName - ) - Row(modifier = Modifier.padding(start = 8.dp)) { - Icon(painterResource(R.drawable.deck), "") - Text(cardName, fontSize = AUTHOR_TEXT_SIZE, fontWeight = FontWeight.Bold) - } - Text(cardDescription, fontSize = AUTHOR_TEXT_SIZE) - } - } - } - } - } - } - } -} +import com.nextcloud.talk.ui.chat.GetView + +// @Suppress("FunctionNaming", "TooManyFunctions", "LongMethod", "StaticFieldLeak", "LargeClass") +// class ComposeChatAdapter( +// var messages: List? = null, +// var messageId: String? = null, +// 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 +// } +// +// 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) +// } +// } @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 @@ -1136,19 +178,13 @@ fun AllMessageTypesPreview() { ) } - 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 + MaterialTheme { Box(modifier = Modifier.fillMaxSize()) { - // Provide a container - adapter.GetView() // Call the main Composable + GetView( + messages = sampleMessages, + messageIdToBlink = "", + user = null + ) } } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/PinnedMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/PinnedMessage.kt index a39930a9d71..a640b1a5cb3 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/PinnedMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/PinnedMessage.kt @@ -76,22 +76,7 @@ fun PinnedMessageView( ) { message.incoming = true - val pinnedHeadline = if (message.pinnedActorId != message.actorId) { - if (message.pinnedActorId == currentConversation?.actorId) { - stringResource( - R.string.pinned_by_you, - message.actorDisplayName.orEmpty() - ) - } else { - stringResource( - R.string.pinned_by_author, - message.actorDisplayName.orEmpty(), - message.pinnedActorDisplayName.orEmpty() - ) - } - } else { - "${message.actorDisplayName}" - } + val pinnedBy = stringResource(R.string.pinned_by) val scrollState = rememberScrollState() diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatUiMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatUiMessage.kt new file mode 100644 index 00000000000..b150cdf0cf5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatUiMessage.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.runtime.Immutable +import com.nextcloud.talk.chat.data.model.ChatMessage + +@Immutable +data class ChatUiMessage(val message: ChatMessage, val avatarUrl: String?) 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..d12922d616e --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -0,0 +1,376 @@ +package com.nextcloud.talk.ui.chat + +import android.util.Log +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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.UnreadMessagesPopup +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.chat.viewmodels.ChatViewModel +import com.nextcloud.talk.data.user.model.User +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private const val LONG_1000 = 1000L +private const val SCROLL_DELAY = 20L +private const val ANIMATION_DURATION = 2500L +private val AUTHOR_TEXT_SIZE = 12.sp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GetNewChatView( + chatItems: List, + conversationThreadId: Long? = null, + onLoadMore: (() -> Unit?)? +) { + val listState = rememberLazyListState() + val displayedChatItems = remember(chatItems) { chatItems } + val showUnreadPopup = remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + val lastNewestIdRef = remember { + object { + var value: String? = null + } + } + + 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) + } else { + showUnreadPopup.value = true + } + } + + lastNewestIdRef.value = newestId + } + + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .map { it <= 2 } + .distinctUntilChanged() + .collect { isNearBottom -> + if (isNearBottom) { + showUnreadPopup.value = false + } + } + } + + 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() + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + reverseLayout = true, + modifier = Modifier + .padding(16.dp) + .fillMaxSize() + ) { + items( + displayedChatItems, + key = { + it.stableKey() + } + ) { chatItem -> + when (chatItem) { + is ChatViewModel.ChatItem.MessageItem -> { + val isBlinkingState = remember { mutableStateOf(false) } + + GetComposableForMessage( + message = chatItem.message, + conversationThreadId = conversationThreadId, + isBlinkingState = isBlinkingState + ) + } + + is ChatViewModel.ChatItem.DateHeaderItem -> { + DateHeader(chatItem.date) + } + } + } + } + + if (showUnreadPopup.value) { + UnreadMessagesPopup( + onClick = { + coroutineScope.launch { + listState.animateScrollToItem(0) + } + showUnreadPopup.value = false + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 20.dp) + ) + } + } +} + +@Composable +fun DateHeader(date: LocalDate) { + 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( + Color.Gray.copy(alpha = 0.2f), + RoundedCornerShape(12.dp) + ) + .padding(horizontal = 12.dp, vertical = 6.dp), + fontSize = 12.sp + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GetView(messages: List, messageIdToBlink: String, user: User?) { + val listState = rememberLazyListState() + val isBlinkingState = remember { mutableStateOf(true) } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + state = listState, + modifier = Modifier.padding(16.dp) + ) { + stickyHeader { + if (messages.isEmpty()) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + ShimmerGroup() + } + } else { + val timestamp = messages[listState.firstVisibleItemIndex].timestamp + val dateString = formatTime(timestamp * LONG_1000) + val color = colorScheme.onSurfaceVariant + 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(messages) { message -> + val incoming = message.actorId != user?.userId + + GetComposableForMessage( + message = message, + isBlinkingState = isBlinkingState + ) + } + } + + if (messages.isNotEmpty()) { + LaunchedEffect(Dispatchers.Main) { + delay(SCROLL_DELAY) + val pos = searchMessages( + messages, + messageIdToBlink + ) + if (pos > 0) { + listState.scrollToItem(pos) + } + delay(ANIMATION_DURATION) + isBlinkingState.value = false + } + } +} + +@Composable +fun GetComposableForMessage( + message: ChatMessage, + conversationThreadId: Long? = null, + isBlinkingState: MutableState = mutableStateOf(false) +) { + when (val type = message.getCalculateMessageType()) { + ChatMessage.MessageType.REGULAR_TEXT_MESSAGE -> { + if (message.isLinkPreview()) { + LinkMessage( + message = message, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } else { + TextMessage( + message = message, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } + } + + ChatMessage.MessageType.SYSTEM_MESSAGE -> { + if (!message.shouldFilter()) { + SystemMessage(message) + } + } + + ChatMessage.MessageType.VOICE_MESSAGE -> { + VoiceMessage( + message = message, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } + + ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE -> { + ImageMessage( + message = message, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } + + ChatMessage.MessageType.SINGLE_NC_GEOLOCATION_MESSAGE -> { + GeolocationMessage( + message = message, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } + + ChatMessage.MessageType.POLL_MESSAGE -> { + PollMessage( + message = message, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } + + ChatMessage.MessageType.DECK_CARD -> { + DeckMessage( + message = message, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } + + else -> { + Log.d("ChatView", "Unknown message type: ${'$'}type") + } + } +} + +private fun ChatMessage.shouldFilter(): Boolean = + systemMessageType in setOf( + ChatMessage.SystemMessageType.REACTION, + ChatMessage.SystemMessageType.REACTION_DELETED, + ChatMessage.SystemMessageType.REACTION_REVOKED, + ChatMessage.SystemMessageType.POLL_VOTED, + ChatMessage.SystemMessageType.MESSAGE_EDITED, + ChatMessage.SystemMessageType.THREAD_CREATED + ) || + (parentMessageId != null && systemMessageType == ChatMessage.SystemMessageType.MESSAGE_DELETED) + +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) +} + +fun searchMessages(messages: List, searchId: String): Int { + messages.forEachIndexed { index, message -> + if (message.id == searchId) return index + } + return -1 +} 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..1e762274379 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt @@ -0,0 +1,63 @@ +/* + * 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.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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.data.model.ChatMessage + +private const val AUTHOR_TEXT_SIZE = 12 + +@Composable +fun DeckMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { + CommonMessageBody( + message = message, + conversationThreadId = conversationThreadId, + playAnimation = state.value, + content = { + Column { + if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { + for (key in message.messageParameters!!.keys) { + val individualHashMap: Map = message.messageParameters!![key]!! + if (individualHashMap["type"] == "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.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..5fe8a4d61f5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/GeolocationMessage.kt @@ -0,0 +1,88 @@ +/* + * 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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import com.nextcloud.talk.chat.data.model.ChatMessage +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker + +private const val MAP_ZOOM = 15.0 + +@Composable +fun GeolocationMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { + CommonMessageBody( + message = message, + conversationThreadId = conversationThreadId, + playAnimation = state.value, + content = { + Column { + if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { + for (key in message.messageParameters!!.keys) { + val individualHashMap: Map = message.messageParameters!![key]!! + if (individualHashMap["type"] == "geo-location") { + val lat = individualHashMap["latitude"] + val lng = individualHashMap["longitude"] + + if (lat != null && lng != null) { + val latitude = lat.toDouble() + val longitude = lng.toDouble() + OpenStreetMap(latitude, longitude) + } + } + } + } + } + } + ) +} + +@Composable +private fun OpenStreetMap(latitude: Double, longitude: Double) { + AndroidView( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(), + factory = { context -> + Configuration.getInstance().userAgentValue = context.packageName + MapView(context).apply { + setTileSource(TileSourceFactory.MAPNIK) + setMultiTouchControls(true) + + val geoPoint = GeoPoint(latitude, longitude) + controller.setCenter(geoPoint) + controller.setZoom(MAP_ZOOM) + + val marker = Marker(this) + marker.position = geoPoint + marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + marker.title = "Location" + overlays.add(marker) + + invalidate() + } + }, + update = { mapView -> + val geoPoint = GeoPoint(latitude, longitude) + mapView.controller.setCenter(geoPoint) + + val marker = mapView.overlays.find { it is Marker } as? Marker + marker?.position = geoPoint + mapView.invalidate() + } + ) +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt new file mode 100644 index 00000000000..19bc0ab439d --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt @@ -0,0 +1,108 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.nextcloud.talk.R +import com.nextcloud.talk.adapters.messages.PreviewMessageViewHolder +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.contacts.load +import com.nextcloud.talk.models.json.chat.ReadStatus +import com.nextcloud.talk.utils.DateUtils +import com.nextcloud.talk.utils.DrawableUtils + +@Composable +fun ImageMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { + val hasCaption = (message.message != "{file}") + + val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) + CommonMessageBody( + message = message, + conversationThreadId = conversationThreadId, + includePadding = false, + playAnimation = state.value, + content = { + Column { + // message.activeUser = adapter.currentUser + val imageUri = message.imageUrl + val mimetype = message.selectedIndividualHashMap!![PreviewMessageViewHolder.KEY_MIMETYPE] + val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(mimetype) + val loadedImage = load(imageUri, LocalContext.current, drawableResourceId) + + AsyncImage( + model = loadedImage, + contentDescription = stringResource(R.string.nc_sent_an_image), + modifier = Modifier + .fillMaxWidth(), + contentScale = ContentScale.FillWidth + ) + + if (hasCaption) { + Text( + message.text, + fontSize = 12.sp, + modifier = Modifier + .widthIn(20.dp, 140.dp) + .padding(8.dp) + ) + } + } + } + ) + + if (!hasCaption) { + Row(modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { + if (!message.incoming) { + Spacer(Modifier.weight(1f)) + } else { + Spacer(Modifier.size(width = 56.dp, 0.dp)) // To account for avatar size + } + Text(message.text, fontSize = 12.sp) + Text( + timeString, + fontSize = 12.sp, + textAlign = TextAlign.End, + modifier = Modifier + .align(Alignment.CenterVertically) + .padding() + .padding(start = 4.dp) + ) + if (message.readStatus == ReadStatus.NONE) { + val read = painterResource(R.drawable.ic_check_all) + Icon( + read, + "", + modifier = Modifier + .padding(start = 4.dp) + .size(16.dp) + .align(Alignment.CenterVertically) + ) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt new file mode 100644 index 00000000000..95f966678b6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt @@ -0,0 +1,70 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import com.nextcloud.talk.chat.data.model.ChatMessage + +private const val REGULAR_TEXT_SIZE = 16 +private const val AUTHOR_TEXT_SIZE = 12 +private const val TIME_TEXT_SIZE = 12 + +@Composable +fun LinkMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { + // val color = colorResource(R.color.high_emphasis_text) + // adapter.viewModel.chatViewModel.getOpenGraph( + // adapter.currentUser.getCredentials(), + // adapter.currentUser.baseUrl!!, + // message.extractedUrlToPreview!! + // ) + // CommonMessageBody(message, playAnimation = state.value) { + // EnrichedText(message) + // Row( + // modifier = Modifier + // .drawWithCache { + // onDrawWithContent { + // drawLine( + // color = color, + // start = Offset.Zero, + // end = Offset(0f, this.size.height), + // strokeWidth = 4f, + // cap = StrokeCap.Round + // ) + // + // drawContent() + // } + // } + // .padding(8.dp) + // .padding(4.dp) + // ) { + // Column { + // val graphObject = adapter.viewModel.chatViewModel.getOpenGraph.asFlow().collectAsState( + // Reference( + // // Dummy class + // ) + // ).value.openGraphObject + // graphObject?.let { + // Text(it.name, fontSize = REGULAR_TEXT_SIZE.sp, fontWeight = FontWeight.Bold) + // it.description?.let { Text(it, fontSize = AUTHOR_TEXT_SIZE.sp) } + // it.link?.let { Text(it, fontSize = TIME_TEXT_SIZE.sp) } + // it.thumb?.let { + // val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image + // val loadedImage = load(it, LocalContext.current, errorPlaceholderImage) + // AsyncImage( + // model = loadedImage, + // contentDescription = stringResource(R.string.nc_sent_an_image), + // modifier = Modifier + // .height(120.dp) + // ) + // } + // } + // } + // } + // } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt new file mode 100644 index 00000000000..b83355666be --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt @@ -0,0 +1,366 @@ +package com.nextcloud.talk.ui.chat + +import android.content.Context +import android.view.View +import android.widget.LinearLayout +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.graphics.ColorUtils +import androidx.emoji2.widget.EmojiTextView +import coil.compose.AsyncImage +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.contacts.loadImage +import com.nextcloud.talk.models.json.chat.ReadStatus +import com.nextcloud.talk.ui.theme.LocalMessageUtils +import com.nextcloud.talk.ui.theme.LocalViewThemeUtils +import com.nextcloud.talk.utils.DateUtils +import com.nextcloud.talk.utils.DisplayUtils + +private val REGULAR_TEXT_SIZE = 16.sp +private val TIME_TEXT_SIZE = 12.sp +private val AUTHOR_TEXT_SIZE = 12.sp +private const val QUOTE_SHAPE_OFFSET = 6 +private const val LINE_SPACING = 1.2f +private const val INT_8 = 8 +private const val HALF_OPACITY = 127 +private const val MESSAGE_LENGTH_THRESHOLD = 25 +private const val ANIMATED_BLINK = 500 + +@Composable +fun CommonMessageBody( + message: ChatMessage, + conversationThreadId: Long? = null, + 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, conversationThreadId) && + 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 viewThemeUtils = LocalViewThemeUtils.current + + val outgoingBubbleColor = viewThemeUtils.talk + .getOutgoingMessageBubbleColor(LocalContext.current, message.isDeleted, false) + + if (message.isDeleted) { + ColorUtils.setAlphaComponent(outgoingBubbleColor, HALF_OPACITY) + } else { + outgoingBubbleColor + } + } + + val shape = if (incoming) { + RoundedCornerShape( + 2.dp, + 20.dp, + 20.dp, + 20.dp + ) + } else { + RoundedCornerShape(20.dp, 2.dp, 20.dp, 20.dp) + } + + val rowModifier = Modifier + // val rowModifier = if (message.id == messageId && playAnimation) { + // Modifier.withCustomAnimation(incoming, shape) + // } else { + // Modifier + // } + + Row( + modifier = rowModifier.fillMaxWidth(), + horizontalArrangement = if (incoming) Arrangement.Start else Arrangement.End + ) { + if (incoming) { + val errorPlaceholderImage: Int = R.drawable.account_circle_96dp + val loadedImage = loadImage(message.avatarUrl, LocalContext.current, errorPlaceholderImage) + AsyncImage( + model = loadedImage, + contentDescription = stringResource(R.string.user_avatar), + modifier = Modifier + .size(48.dp) + .align(Alignment.CenterVertically) + .padding(end = 8.dp) + ) + } else { + Spacer(Modifier.width(8.dp)) + } + + Surface( + modifier = Modifier + .defaultMinSize(60.dp, 40.dp) + .widthIn(60.dp, 280.dp) + .heightIn(40.dp, 450.dp), + color = Color(color), + shape = shape + ) { + val modifier = if (includePadding) { + Modifier.padding(16.dp, 4.dp, 16.dp, 4.dp) + } else { + Modifier + } + + Column(modifier = modifier) { + // TODO implement CommonMessageQuote usage + // if (messages != null && + // message.parentMessageId != null && + // !message.isDeleted && + // message.parentMessageId.toString() != threadId + // ) { + // messages!! + // .find { it.parentMessageId == message.parentMessageId } + // .let { CommonMessageQuote(LocalContext.current, it!!) } + // } + + if (incoming) { + Text( + message.actorDisplayName.toString(), + fontSize = AUTHOR_TEXT_SIZE, + color = colorScheme.onSurfaceVariant + ) + } + + 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) + } + } + } + } + } +} + +@Composable +fun CommonMessageQuote(context: Context, message: ChatMessage, incoming: Boolean) { + val color = colorResource(R.color.high_emphasis_text) + Row( + modifier = Modifier + .drawWithCache { + onDrawWithContent { + drawLine( + color = color, + start = Offset(0f, this.size.height / QUOTE_SHAPE_OFFSET), + end = Offset(0f, this.size.height - (this.size.height / QUOTE_SHAPE_OFFSET)), + strokeWidth = 4f, + cap = StrokeCap.Round + ) + + drawContent() + } + } + .padding(8.dp) + ) { + Column { + Text(message.actorDisplayName!!, fontSize = AUTHOR_TEXT_SIZE) + val imageUri = message.imageUrl + if (imageUri != null) { + val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image + val loadedImage = loadImage(imageUri, context, errorPlaceholderImage) + AsyncImage( + model = loadedImage, + contentDescription = stringResource(R.string.nc_sent_an_image), + modifier = Modifier + .padding(8.dp) + .fillMaxHeight() + ) + } + EnrichedText( + message + ) + } + } +} + +private fun getColorFromTheme(context: Context, resourceId: Int): Int { + val isDarkMode = DisplayUtils.isAppThemeDarkMode(context) + val nightConfig = android.content.res.Configuration() + nightConfig.uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES + val nightContext = context.createConfigurationContext(nightConfig) + + return if (isDarkMode) { + nightContext.getColor(resourceId) + } else { + context.getColor(resourceId) + } +} + +@Composable +fun TimeDisplay(message: ChatMessage) { + val timeString = DateUtils(LocalContext.current) + .getLocalTimeStringFromTimestamp(message.timestamp) + Text( + timeString, + fontSize = TIME_TEXT_SIZE, + textAlign = TextAlign.Center, + color = colorScheme.onSurfaceVariant + ) +} + +@Composable +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 = colorScheme.onSurfaceVariant + ) + } +} + +@Composable +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 + ) + } + } +} + +@Composable +fun EnrichedText(message: ChatMessage) { + val viewThemeUtils = LocalViewThemeUtils.current + val messageUtils = LocalMessageUtils.current + + AndroidView(factory = { ctx -> + var processedMessageText = messageUtils.enrichChatMessageText( + context = ctx, + message = message, + incoming = message.incoming, + viewThemeUtils = viewThemeUtils + ) + + processedMessageText = messageUtils.processMessageParameters( + themingContext = ctx, + viewThemeUtils = viewThemeUtils, + spannedText = processedMessageText!!, + message = message, + itemView = null + ) + + EmojiTextView(ctx).apply { + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + setLineSpacing(0F, LINE_SPACING) + textAlignment = View.TEXT_ALIGNMENT_VIEW_START + text = processedMessageText + setPadding(0, INT_8, 0, 0) + } + }, modifier = Modifier) +} + +fun isFirstMessageOfThreadInNormalChat(message: ChatMessage, conversationThreadId: Long? = null): Boolean = + conversationThreadId == null && message.isThread + +@Composable +private fun Modifier.withCustomAnimation(incoming: Boolean, shape: RoundedCornerShape): Modifier { + val infiniteTransition = rememberInfiniteTransition() + val borderColor by infiniteTransition.animateColor( + initialValue = colorScheme.primary, + targetValue = colorScheme.background, + animationSpec = infiniteRepeatable( + animation = tween(ANIMATED_BLINK, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ) + ) + + return this.border( + width = 4.dp, + color = borderColor, + shape = shape + ) +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/PollMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/PollMessage.kt new file mode 100644 index 00000000000..3abe426b626 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/PollMessage.kt @@ -0,0 +1,69 @@ +/* + * 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.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.data.model.ChatMessage + +private const val AUTHOR_TEXT_SIZE = 12 + +@Composable +fun PollMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { + CommonMessageBody( + message = message, + conversationThreadId = conversationThreadId, + playAnimation = state.value, + content = { + 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.sp, fontWeight = FontWeight.Bold) + } + + TextButtonNoStyling(stringResource(R.string.message_poll_tap_to_open)) { + // NOTE: read only for now + } + } + } + } + } + } + ) +} + +@Composable +private fun TextButtonNoStyling(text: String, onClick: () -> Unit) { + TextButton(onClick = onClick) { + Text( + text, + fontSize = AUTHOR_TEXT_SIZE.sp, + color = Color.White + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/Shimmer.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/Shimmer.kt new file mode 100644 index 00000000000..a629c0778dd --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/Shimmer.kt @@ -0,0 +1,118 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import android.widget.LinearLayout +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.elyeproj.loaderviewlibrary.LoaderImageView +import com.elyeproj.loaderviewlibrary.LoaderTextView +import com.nextcloud.talk.R + +private const val INT_8 = 8 +private const val INT_128 = 128 + +@Composable +fun ShimmerGroup() { + Shimmer() + Shimmer(true) + Shimmer() + Shimmer(true) + Shimmer(true) + Shimmer() + Shimmer(true) +} + +@Composable +private fun Shimmer(outgoing: Boolean = false) { + val outgoingColor = colorScheme.primary.toArgb() + + Row(modifier = Modifier.padding(top = 16.dp)) { + if (!outgoing) { + ShimmerImage(this) + } + + val v1 by remember { mutableIntStateOf((INT_8..INT_128).random()) } + val v2 by remember { mutableIntStateOf((INT_8..INT_128).random()) } + val v3 by remember { mutableIntStateOf((INT_8..INT_128).random()) } + + Column { + ShimmerText(this, v1, outgoing, outgoingColor) + ShimmerText(this, v2, outgoing, outgoingColor) + ShimmerText(this, v3, outgoing, outgoingColor) + } + } +} + +@Composable +private fun ShimmerImage(rowScope: RowScope) { + rowScope.apply { + AndroidView( + factory = { ctx -> + LoaderImageView(ctx).apply { + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + val color = resources.getColor(R.color.nc_shimmer_default_color, null) + setBackgroundColor(color) + } + }, + modifier = Modifier + .clip(CircleShape) + .size(40.dp) + .align(Alignment.Top) + ) + } +} + +@Composable +private fun ShimmerText(columnScope: ColumnScope, margin: Int, outgoing: Boolean = false, outgoingColor: Int) { + columnScope.apply { + AndroidView( + factory = { ctx -> + LoaderTextView(ctx).apply { + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + val color = if (outgoing) { + outgoingColor + } else { + resources.getColor(R.color.nc_shimmer_default_color, null) + } + + setBackgroundColor(color) + } + }, + modifier = Modifier.padding( + top = 6.dp, + end = if (!outgoing) margin.dp else 8.dp, + start = if (outgoing) margin.dp else 8.dp + ) + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt new file mode 100644 index 00000000000..0aac00bebf0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt @@ -0,0 +1,72 @@ +package com.nextcloud.talk.ui.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.graphics.Color +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.R +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.utils.DateUtils + +private const val AUTHOR_TEXT_SIZE = 12 +private const val TIME_TEXT_SIZE = 12 +private const val FLOAT_06 = 0.6f + +@Composable +fun SystemMessage(message: ChatMessage) { + val similarMessages = NextcloudTalkApplication.sharedApplication!!.resources.getQuantityString( + R.plurals.see_similar_system_messages, + message.expandableChildrenAmount, + message.expandableChildrenAmount + ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) + Row(horizontalArrangement = Arrangement.Absolute.Center, verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.weight(1f)) + Text( + message.text, + fontSize = AUTHOR_TEXT_SIZE.sp, + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(FLOAT_06) + ) + Text( + timeString, + fontSize = TIME_TEXT_SIZE.sp, + textAlign = TextAlign.End, + modifier = Modifier.align(Alignment.CenterVertically) + ) + Spacer(modifier = Modifier.weight(1f)) + } + + if (message.expandableChildrenAmount > 0) { + TextButtonNoStyling(similarMessages) { + // NOTE: Read only for now + } + } + } +} + +@Composable +private fun TextButtonNoStyling(text: String, onClick: () -> Unit) { + TextButton(onClick = onClick) { + Text( + text, + fontSize = AUTHOR_TEXT_SIZE.sp, + color = Color.White + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt new file mode 100644 index 00000000000..fe2b8eae56a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import com.nextcloud.talk.chat.data.model.ChatMessage + +@Composable +fun TextMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { + CommonMessageBody( + message = message, + conversationThreadId = conversationThreadId, + playAnimation = state.value, + content = { + EnrichedText( + message + ) + } + ) +} 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..5bd28c37c19 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt @@ -0,0 +1,67 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +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.data.model.ChatMessage +import com.nextcloud.talk.ui.WaveformSeekBar +import kotlin.random.Random + +private const val DEFAULT_WAVE_SIZE = 50 + +@Composable +fun VoiceMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { + CommonMessageBody( + message = message, + conversationThreadId = conversationThreadId, + playAnimation = state.value, + content = { + val inversePrimary = colorScheme.inversePrimary.toArgb() + val onPrimaryContainer = colorScheme.onPrimaryContainer.toArgb() + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.PlayArrow, + contentDescription = "play", + modifier = Modifier.size(24.dp) + ) + + AndroidView( + factory = { ctx -> + WaveformSeekBar(ctx).apply { + setWaveData(FloatArray(DEFAULT_WAVE_SIZE) { Random.nextFloat() }) + setColors( + inversePrimary, + onPrimaryContainer + ) + } + }, + modifier = Modifier + .width(180.dp) + .height(80.dp) + ) + } + } + ) +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt index 8df5f2ad15d..25c35dd4185 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/DateTimeCompose.kt @@ -77,7 +77,7 @@ import java.time.temporal.TemporalAdjusters.nextOrSame import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) -class DateTimeCompose(val bundle: Bundle) { +class DateTimeCompose(val bundle: Bundle, val chatViewModel: ChatViewModel) { private var timeState = mutableStateOf(LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.MIN)) init { @@ -89,9 +89,6 @@ class DateTimeCompose(val bundle: Bundle) { chatViewModel.getReminder(user, roomToken, messageId, apiVersion) } - @Inject - lateinit var chatViewModel: ChatViewModel - @Inject lateinit var currentUserProvider: CurrentUserProviderOld diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/CompositionLocals.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/CompositionLocals.kt new file mode 100644 index 00000000000..101e5813521 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/CompositionLocals.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.theme + +import androidx.compose.runtime.staticCompositionLocalOf +import com.nextcloud.talk.utils.message.MessageUtils + +val LocalViewThemeUtils = staticCompositionLocalOf { + error("ViewThemeUtils not provided") +} + +val LocalMessageUtils = staticCompositionLocalOf { + error("MessageUtils not provided") +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt index db7095f025b..3e8b17e7f1b 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt @@ -47,6 +47,7 @@ import com.nextcloud.talk.ui.theme.MaterialSchemesProviderImpl import com.nextcloud.talk.ui.theme.TalkSpecificViewThemeUtils import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.database.user.CurrentUserProviderImpl import com.nextcloud.talk.utils.database.user.CurrentUserProviderOldImpl import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.message.MessageUtils @@ -171,16 +172,22 @@ class ComposePreviewUtils private constructor(context: Context) { val audioFocusRequestManager: AudioFocusRequestManager get() = AudioFocusRequestManager(mContext) + val currentUserProvider: CurrentUserProviderImpl + get() = CurrentUserProviderImpl(userManager) + val chatViewModel: ChatViewModel get() = ChatViewModel( - appPreferences, - chatNetworkDataSource, - chatRepository, - threadsRepository, - conversationRepository, - reactionsRepository, - mediaRecorderManager, - audioFocusRequestManager + appPreferences = appPreferences, + chatNetworkDataSource = chatNetworkDataSource, + chatRepository = chatRepository, + threadsRepository = threadsRepository, + conversationRepository = conversationRepository, + reactionsRepository = reactionsRepository, + mediaRecorderManager = mediaRecorderManager, + audioFocusRequestManager = audioFocusRequestManager, + currentUserProvider = currentUserProvider, + chatRoomToken = "", + conversationThreadId = null ) val contactsRepository: ContactsRepository diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt index 1b85bdcd4b4..98e932ea417 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt @@ -23,7 +23,16 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf class DummyChatMessagesDaoImpl : ChatMessagesDao { - override fun getMessagesForConversation(internalConversationId: String): Flow> = flowOf() + override fun getMessagesNewerThan( + internalConversationId: String, + threadId: Long?, + oldestMessageId: Long + ): Flow> = flowOf() + + override fun getMessagesForConversation( + internalConversationId: String, + threadId: Long? + ): Flow> = flowOf() override fun getTempMessagesForConversation(internalConversationId: String): Flow> = flowOf() @@ -58,6 +67,13 @@ class DummyChatMessagesDaoImpl : ChatMessagesDao { override suspend fun getChatMessageEntity(internalConversationId: String, messageId: Long): ChatMessageEntity? = null + override suspend fun getChatMessageOnce(internalConversationId: String, messageId: Long): ChatMessageEntity? = null + + override fun getChatMessageForConversationNullable( + internalConversationId: String, + messageId: Long + ): Flow = flowOf() + override fun deleteChatMessages(internalIds: List) { /* */ } @@ -259,4 +275,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/WebSocketInstance.kt b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt index 81b784726e3..9e6a3f2fec6 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt @@ -183,7 +183,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU ncSignalingMessage.from = callWebSocketMessage.senderWebSocketMessage!!.sessionId } - signalingMessageReceiver.process(callWebSocketMessage) + signalingMessageReceiver.processChatMessage(callWebSocketMessage) } } @@ -196,17 +196,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 +217,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 +231,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")) { @@ -468,11 +472,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 +485,11 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU processSignalingMessage(message?.ncSignalingMessage) } } + + fun processChatMessage(jsonString: String) { + processChatMessageWebSocketMessage(jsonString) + Log.d(TAG, "processing Received chat message") + } } inner class ExternalSignalingMessageSender : SignalingMessageSender { diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index 52e223c505d..421c3badf23 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -13,7 +13,6 @@ android:id="@+id/chat_container" android:layout_width="match_parent" android:layout_height="match_parent" - android:animateLayoutChanges="true" android:background="@color/bg_default" android:orientation="vertical" tools:ignore="Overdraw"> @@ -29,7 +28,6 @@ android:layout_height="?attr/actionBarSize" android:background="@color/appbar" android:theme="?attr/actionBarPopupTheme" - app:layout_scrollFlags="scroll|enterAlways" app:navigationIconTint="@color/fontAppbar" app:popupTheme="@style/appActionBarPopupMenu"> @@ -140,42 +138,55 @@ - + + + + + + - app:dateHeaderTextSize="13sp" - app:incomingBubblePaddingBottom="@dimen/message_bubble_corners_vertical_padding" - app:incomingBubblePaddingLeft="@dimen/message_bubble_corners_horizontal_padding" - app:incomingBubblePaddingRight="@dimen/message_bubble_corners_horizontal_padding" - app:incomingBubblePaddingTop="@dimen/message_bubble_corners_vertical_padding" - app:incomingDefaultBubbleColor="@color/bg_message_list_incoming_bubble" - app:incomingDefaultBubblePressedColor="@color/bg_message_list_incoming_bubble" - app:incomingDefaultBubbleSelectedColor="@color/transparent" - app:incomingImageTimeTextSize="12sp" - app:incomingTextColor="@color/nc_incoming_text_default" - app:incomingTextLinkColor="@color/nc_incoming_text_default" - app:incomingTextSize="@dimen/chat_text_size" - app:incomingTimeTextColor="@color/no_emphasis_text" - app:incomingTimeTextSize="12sp" - app:outcomingBubblePaddingBottom="@dimen/message_bubble_corners_vertical_padding" - app:outcomingBubblePaddingLeft="@dimen/message_bubble_corners_horizontal_padding" - app:outcomingBubblePaddingRight="@dimen/message_bubble_corners_horizontal_padding" - app:outcomingBubblePaddingTop="@dimen/message_bubble_corners_vertical_padding" - app:outcomingDefaultBubbleColor="@color/colorPrimary" - app:outcomingDefaultBubblePressedColor="@color/colorPrimary" - app:outcomingDefaultBubbleSelectedColor="@color/transparent" - app:outcomingImageTimeTextSize="12sp" - app:outcomingTextColor="@color/high_emphasis_text" - app:outcomingTextLinkColor="@color/high_emphasis_text" - app:outcomingTextSize="@dimen/chat_text_size" - app:outcomingTimeTextSize="12sp" - app:textAutoLink="all" - tools:visibility="visible" /> + + Date: Fri, 6 Feb 2026 16:49:09 +0100 Subject: [PATCH 02/19] fixes after resolving merge conflicts Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 8 +------- .../java/com/nextcloud/talk/ui/PinnedMessage.kt | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 8 deletions(-) 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 62a2617df69..2b02eee07f5 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -84,12 +84,10 @@ import androidx.emoji2.text.EmojiCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.commit import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -177,6 +175,7 @@ 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 @@ -243,15 +242,11 @@ 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.collectLatest -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -760,7 +755,6 @@ class ChatActivity : } @OptIn(FlowPreview::class) - @OptIn(ExperimentalCoroutinesApi::class) @SuppressLint("NotifyDataSetChanged", "SetTextI18n", "ResourceAsColor") @Suppress("LongMethod") private fun initObservers() { diff --git a/app/src/main/java/com/nextcloud/talk/ui/PinnedMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/PinnedMessage.kt index a640b1a5cb3..a39930a9d71 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/PinnedMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/PinnedMessage.kt @@ -76,7 +76,22 @@ fun PinnedMessageView( ) { message.incoming = true - val pinnedBy = stringResource(R.string.pinned_by) + val pinnedHeadline = if (message.pinnedActorId != message.actorId) { + if (message.pinnedActorId == currentConversation?.actorId) { + stringResource( + R.string.pinned_by_you, + message.actorDisplayName.orEmpty() + ) + } else { + stringResource( + R.string.pinned_by_author, + message.actorDisplayName.orEmpty(), + message.pinnedActorDisplayName.orEmpty() + ) + } + } else { + "${message.actorDisplayName}" + } val scrollState = rememberScrollState() From 3a8df1a36799e5f80d434d9dc0e68a7fbde5f08c Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 6 Feb 2026 17:15:49 +0100 Subject: [PATCH 03/19] own sticky header with reverseLayout = true Signed-off-by: Marcel Hibbe --- .../talk/chat/viewmodels/ChatViewModel.kt | 1 + .../com/nextcloud/talk/ui/chat/ChatView.kt | 187 +++++++++++++----- 2 files changed, 144 insertions(+), 44 deletions(-) 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 928cc1fe0cb..598e0d78251 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 @@ -1328,6 +1328,7 @@ class ChatViewModel @AssistedInject constructor( sealed interface ChatItem { fun messageOrNull(): ChatMessage? = (this as? MessageItem)?.message + fun dateOrNull(): LocalDate? = (this as? DateHeaderItem)?.date fun stableKey(): Any = when (this) { 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 index d12922d616e..4d9867dccc6 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -1,6 +1,13 @@ package com.nextcloud.talk.ui.chat import android.util.Log +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 @@ -11,21 +18,32 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +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.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalResources @@ -36,7 +54,6 @@ import com.nextcloud.talk.chat.UnreadMessagesPopup import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.data.user.model.User -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -47,8 +64,6 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter private const val LONG_1000 = 1000L -private const val SCROLL_DELAY = 20L -private const val ANIMATION_DURATION = 2500L private val AUTHOR_TEXT_SIZE = 12.sp @OptIn(ExperimentalFoundationApi::class) @@ -59,7 +74,6 @@ fun GetNewChatView( onLoadMore: (() -> Unit?)? ) { val listState = rememberLazyListState() - val displayedChatItems = remember(chatItems) { chatItems } val showUnreadPopup = remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() @@ -69,15 +83,28 @@ fun GetNewChatView( } } + val isAtNewest by remember(listState) { + derivedStateOf { + listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset == 0 + } + } + + val showScrollToNewest by remember { + derivedStateOf { !isAtNewest } + } + LaunchedEffect(chatItems) { if (chatItems.isEmpty()) return@LaunchedEffect - val newestId = chatItems.firstNotNullOfOrNull { it.messageOrNull()?.id } + val newestId = + chatItems.firstNotNullOfOrNull { it.messageOrNull()?.id } val previousNewestId = lastNewestIdRef.value val isNearBottom = listState.firstVisibleItemIndex <= 2 - val hasNewMessage = previousNewestId != null && newestId != previousNewestId + val hasNewMessage = + previousNewestId != null && newestId != previousNewestId if (hasNewMessage) { if (isNearBottom) { @@ -94,10 +121,8 @@ fun GetNewChatView( snapshotFlow { listState.firstVisibleItemIndex } .map { it <= 2 } .distinctUntilChanged() - .collect { isNearBottom -> - if (isNearBottom) { - showUnreadPopup.value = false - } + .collect { nearBottom -> + if (nearBottom) showUnreadPopup.value = false } } @@ -105,9 +130,7 @@ fun GetNewChatView( snapshotFlow { val layoutInfo = listState.layoutInfo val total = layoutInfo.totalItemsCount - val lastVisible = - layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 lastVisible to total } .distinctUntilChanged() @@ -115,8 +138,7 @@ fun GetNewChatView( if (total == 0) return@collect val buffer = 5 - val shouldLoadMore = - lastVisible >= (total - 1 - buffer) + val shouldLoadMore = lastVisible >= (total - 1 - buffer) if (shouldLoadMore) { onLoadMore?.invoke() @@ -124,24 +146,67 @@ fun GetNewChatView( } } + 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.message.timestamp * LONG_1000) + + is ChatViewModel.ChatItem.DateHeaderItem -> + formatTime(item.date) + } + } ?: "" + } + } + + var stickyDateHeader by remember { mutableStateOf(false) } + + LaunchedEffect(listState) { + snapshotFlow { listState.isScrollInProgress } + .collect { scrolling -> + if (scrolling) { + stickyDateHeader = true + } else { + delay(1200) + stickyDateHeader = false + } + } + } + + 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, - verticalArrangement = Arrangement.spacedBy(8.dp), reverseLayout = true, + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier - .padding(16.dp) + .padding( + start = 12.dp, + end = 12.dp + ) .fillMaxSize() ) { items( - displayedChatItems, - key = { - it.stableKey() - } + chatItems, + key = { it.stableKey() } ) { chatItem -> + when (chatItem) { is ChatViewModel.ChatItem.MessageItem -> { - val isBlinkingState = remember { mutableStateOf(false) } + val isBlinkingState = + remember { mutableStateOf(false) } GetComposableForMessage( message = chatItem.message, @@ -157,11 +222,28 @@ fun GetNewChatView( } } + Surface( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 12.dp) + .alpha(stickyDateHeaderAlpha), + shape = RoundedCornerShape(16.dp), + tonalElevation = 2.dp + ) { + Text( + stickyDateHeaderText, + modifier = Modifier.padding( + horizontal = 12.dp, + vertical = 6.dp + ) + ) + } + if (showUnreadPopup.value) { UnreadMessagesPopup( onClick = { coroutineScope.launch { - listState.animateScrollToItem(0) + listState.scrollToItem(0) } showUnreadPopup.value = false }, @@ -170,6 +252,35 @@ fun GetNewChatView( .padding(bottom = 20.dp) ) } + + 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) + } + }, + 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) + ) + } + } } } @@ -199,6 +310,7 @@ fun DateHeader(date: LocalDate) { } } +@Deprecated("do not use Compose Chat Adapter") @OptIn(ExperimentalFoundationApi::class) @Composable fun GetView(messages: List, messageIdToBlink: String, user: User?) { @@ -258,21 +370,6 @@ fun GetView(messages: List, messageIdToBlink: String, user: User?) ) } } - - if (messages.isNotEmpty()) { - LaunchedEffect(Dispatchers.Main) { - delay(SCROLL_DELAY) - val pos = searchMessages( - messages, - messageIdToBlink - ) - if (pos > 0) { - listState.scrollToItem(pos) - } - delay(ANIMATION_DURATION) - isBlinkingState.value = false - } - } } @Composable @@ -364,13 +461,15 @@ private fun ChatMessage.shouldFilter(): Boolean = 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) + return formatTime(dateTime) } -fun searchMessages(messages: List, searchId: String): Int { - messages.forEachIndexed { index, message -> - if (message.id == searchId) return index +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 -1 + return text } From 5ca4c628c1d1eebc9d091c8adfdb5e6e1f5d1c68 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 6 Feb 2026 23:41:26 +0100 Subject: [PATCH 04/19] count new messages Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/ui/chat/ChatView.kt | 94 +++++++++++-------- 1 file changed, 55 insertions(+), 39 deletions(-) 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 index 4d9867dccc6..42b367a6b47 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -36,6 +37,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState 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 @@ -78,11 +80,13 @@ fun GetNewChatView( val coroutineScope = rememberCoroutineScope() val lastNewestIdRef = remember { - object { - var value: String? = null - } + object { var value: String? = 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 && @@ -90,26 +94,25 @@ fun GetNewChatView( } } - val showScrollToNewest by remember { - derivedStateOf { !isAtNewest } - } + // Show floating scroll-to-newest button when not at newest + val showScrollToNewest by remember { derivedStateOf { !isAtNewest } } + // Track newest message and show unread popup LaunchedEffect(chatItems) { if (chatItems.isEmpty()) return@LaunchedEffect - val newestId = - chatItems.firstNotNullOfOrNull { it.messageOrNull()?.id } - + val newestId = chatItems.firstNotNullOfOrNull { it.messageOrNull()?.id } val previousNewestId = lastNewestIdRef.value val isNearBottom = listState.firstVisibleItemIndex <= 2 - val hasNewMessage = - previousNewestId != null && newestId != previousNewestId + val hasNewMessage = previousNewestId != null && newestId != previousNewestId if (hasNewMessage) { if (isNearBottom) { listState.animateScrollToItem(0) + unreadCount = 0 } else { + unreadCount++ showUnreadPopup.value = true } } @@ -117,15 +120,20 @@ fun GetNewChatView( 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 + if (nearBottom) { + showUnreadPopup.value = false + unreadCount = 0 + } } } + // Load more when near end LaunchedEffect(listState, chatItems.size) { snapshotFlow { val layoutInfo = listState.layoutInfo @@ -146,12 +154,12 @@ fun GetNewChatView( } } + // Sticky date header val stickyDateHeaderText by remember(listState, chatItems) { derivedStateOf { chatItems.getOrNull( listState.layoutInfo.visibleItemsInfo - .lastOrNull() - ?.index ?: 0 + .lastOrNull()?.index ?: 0 )?.let { item -> when (item) { is ChatViewModel.ChatItem.MessageItem -> @@ -180,9 +188,7 @@ fun GetNewChatView( val stickyDateHeaderAlpha by animateFloatAsState( targetValue = if (stickyDateHeader && stickyDateHeaderText.isNotEmpty()) 1f else 0f, - animationSpec = tween( - durationMillis = if (stickyDateHeader) 500 else 1000 - ), + animationSpec = tween(durationMillis = if (stickyDateHeader) 500 else 1000), label = "" ) @@ -191,23 +197,15 @@ fun GetNewChatView( state = listState, reverseLayout = true, verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(bottom = 20.dp), modifier = Modifier - .padding( - start = 12.dp, - end = 12.dp - ) + .padding(start = 12.dp, end = 12.dp) .fillMaxSize() ) { - items( - chatItems, - key = { it.stableKey() } - ) { chatItem -> - + items(chatItems, key = { it.stableKey() }) { chatItem -> when (chatItem) { is ChatViewModel.ChatItem.MessageItem -> { - val isBlinkingState = - remember { mutableStateOf(false) } - + val isBlinkingState = remember { mutableStateOf(false) } GetComposableForMessage( message = chatItem.message, conversationThreadId = conversationThreadId, @@ -222,6 +220,7 @@ fun GetNewChatView( } } + // Sticky date header Surface( modifier = Modifier .align(Alignment.TopCenter) @@ -232,19 +231,17 @@ fun GetNewChatView( ) { Text( stickyDateHeaderText, - modifier = Modifier.padding( - horizontal = 12.dp, - vertical = 6.dp - ) + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) ) } + // Unread messages popup if (showUnreadPopup.value) { UnreadMessagesPopup( + unreadCount = unreadCount, onClick = { - coroutineScope.launch { - listState.scrollToItem(0) - } + coroutineScope.launch { listState.scrollToItem(0) } + unreadCount = 0 showUnreadPopup.value = false }, modifier = Modifier @@ -253,6 +250,7 @@ fun GetNewChatView( ) } + // Floating scroll-to-newest button AnimatedVisibility( visible = showScrollToNewest, modifier = Modifier @@ -263,9 +261,8 @@ fun GetNewChatView( ) { Surface( onClick = { - coroutineScope.launch { - listState.scrollToItem(0) - } + coroutineScope.launch { listState.scrollToItem(0) } + unreadCount = 0 }, shape = CircleShape, color = colorScheme.surface.copy(alpha = 0.9f), @@ -284,6 +281,25 @@ fun GetNewChatView( } } +@Composable +fun UnreadMessagesPopup( + unreadCount: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(20.dp), + tonalElevation = 3.dp, + modifier = modifier + ) { + Text( + text = "$unreadCount new message${if (unreadCount > 1) "s" else ""}", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp) + ) + } +} + @Composable fun DateHeader(date: LocalDate) { val text = when (date) { From 19d0d13f4853ef05215626c46c6ef3f0f86988f6 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Sat, 7 Feb 2026 01:04:04 +0100 Subject: [PATCH 05/19] do not show sticky header when new message is received and user is near newest message Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/ui/chat/ChatView.kt | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) 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 index 42b367a6b47..e1975024421 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -57,6 +57,7 @@ import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.data.user.model.User import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -94,6 +95,12 @@ fun GetNewChatView( } } + 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 } } @@ -174,16 +181,22 @@ fun GetNewChatView( var stickyDateHeader by remember { mutableStateOf(false) } - LaunchedEffect(listState) { - snapshotFlow { listState.isScrollInProgress } - .collect { scrolling -> - if (scrolling) { - stickyDateHeader = true - } else { - delay(1200) - stickyDateHeader = 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) { + snapshotFlow { listState.isScrollInProgress } + .collectLatest { scrolling -> + if (scrolling) { + stickyDateHeader = true + } else { + delay(1200) + stickyDateHeader = false + } } - } + } else { + stickyDateHeader = false + } } val stickyDateHeaderAlpha by animateFloatAsState( From 5ccdb27ded4316a0b378bd34ce43e974e6445672 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Mon, 9 Feb 2026 13:50:20 +0100 Subject: [PATCH 06/19] add handling to automatically set messages as read Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 17 ++++++++---- .../com/nextcloud/talk/ui/chat/ChatView.kt | 27 +++++++++++++++---- 2 files changed, 34 insertions(+), 10 deletions(-) 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 2b02eee07f5..aa56151d98e 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -640,7 +640,9 @@ class ChatActivity : GetNewChatView( chatItems = chatItems, conversationThreadId = conversationThreadId, - onLoadMore = { loadMoreMessagesCompose() } + onLoadMore = { loadMoreMessagesCompose() }, + advanceLocalLastReadMessageIfNeeded = { advanceLocalLastReadMessageIfNeeded(it) }, + updateRemoteLastReadMessageIfNeeded = { updateRemoteLastReadMessageIfNeeded() } ) } } @@ -670,7 +672,7 @@ class ChatActivity : // With Jetpack Compose the flow will be used directly in the UI instead to clear and add everything. adapter!!.clear() adapter!!.addToEnd(chatMessages, false) - advanceLocalLastReadMessageIfNeeded() + advanceLocalLastReadMessageIfNeededChatKit() } else { Log.e(TAG, "adapter was null") } @@ -1577,7 +1579,7 @@ class ChatActivity : super.onScrollStateChanged(recyclerView, newState) if (newState == AbsListView.OnScrollListener.SCROLL_STATE_IDLE) { - advanceLocalLastReadMessageIfNeeded() + advanceLocalLastReadMessageIfNeededChatKit() updateRemoteLastReadMessageIfNeeded() if (isScrolledToBottom()) { binding.unreadMessagesPopup.visibility = View.GONE @@ -2895,18 +2897,23 @@ class ChatActivity : adapter = null } - private fun advanceLocalLastReadMessageIfNeeded() { + @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 -> - chatViewModel.advanceLocalLastReadMessageIfNeeded(messageId) + advanceLocalLastReadMessageIfNeeded(messageId) } } } + private fun advanceLocalLastReadMessageIfNeeded(messageId: Int) { + chatViewModel.advanceLocalLastReadMessageIfNeeded(messageId) + } + private fun updateRemoteLastReadMessageIfNeeded() { val url = ApiUtils.getUrlForChatReadMarker( ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(ApiUtils.API_V1)), 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 index e1975024421..dff68083e29 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -41,6 +40,7 @@ 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 @@ -52,7 +52,6 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nextcloud.talk.R -import com.nextcloud.talk.chat.UnreadMessagesPopup import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.data.user.model.User @@ -74,7 +73,9 @@ private val AUTHOR_TEXT_SIZE = 12.sp fun GetNewChatView( chatItems: List, conversationThreadId: Long? = null, - onLoadMore: (() -> Unit?)? + onLoadMore: (() -> Unit?)?, + advanceLocalLastReadMessageIfNeeded: ((Int) -> Unit?)?, + updateRemoteLastReadMessageIfNeeded: (() -> Unit?)? ) { val listState = rememberLazyListState() val showUnreadPopup = remember { mutableStateOf(false) } @@ -104,6 +105,8 @@ fun GetNewChatView( // 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 @@ -165,8 +168,7 @@ fun GetNewChatView( val stickyDateHeaderText by remember(listState, chatItems) { derivedStateOf { chatItems.getOrNull( - listState.layoutInfo.visibleItemsInfo - .lastOrNull()?.index ?: 0 + listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 )?.let { item -> when (item) { is ChatViewModel.ChatItem.MessageItem -> @@ -185,6 +187,7 @@ fun GetNewChatView( // 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) { @@ -199,6 +202,20 @@ fun GetNewChatView( } } + 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.message.jsonMessageId) + } + } + } + val stickyDateHeaderAlpha by animateFloatAsState( targetValue = if (stickyDateHeader && stickyDateHeaderText.isNotEmpty()) 1f else 0f, animationSpec = tween(durationMillis = if (stickyDateHeader) 500 else 1000), From d7a1ee13fc699bb571913a712608f4ef67d0a934 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 12 Feb 2026 12:06:32 +0100 Subject: [PATCH 07/19] format code Signed-off-by: Marcel Hibbe --- .../java/com/nextcloud/talk/chat/ChatActivity.kt | 5 +++-- .../com/nextcloud/talk/chat/UnreadMessagesPopup.kt | 5 +---- .../talk/chat/viewmodels/ChatViewModel.kt | 14 +++++--------- .../nextcloud/talk/contextchat/ContextChatView.kt | 1 - .../shareditems/activities/SharedItemsActivity.kt | 4 +++- .../java/com/nextcloud/talk/ui/chat/ChatView.kt | 12 +++++------- 6 files changed, 17 insertions(+), 24 deletions(-) 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 aa56151d98e..f56d9bab6e8 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -412,7 +412,9 @@ class ChatActivity : val conversationThreadId: Long? by lazy { if (intent.hasExtra(KEY_THREAD_ID)) { intent.getLongExtra(KEY_THREAD_ID, 0L) - } else null + } else { + null + } } var openedViaNotification: Boolean = false @@ -3379,7 +3381,6 @@ class ChatActivity : private fun isSameDayMessages(message1: ChatMessage, message2: ChatMessage): Boolean = DateFormatter.isSameDay(message1.createdAt, message2.createdAt) - private fun loadMoreMessagesCompose() { val currentItems = chatViewModel.chatItems.value diff --git a/app/src/main/java/com/nextcloud/talk/chat/UnreadMessagesPopup.kt b/app/src/main/java/com/nextcloud/talk/chat/UnreadMessagesPopup.kt index c61c7b47532..67e48e2121f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/UnreadMessagesPopup.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/UnreadMessagesPopup.kt @@ -25,10 +25,7 @@ import androidx.compose.ui.unit.dp import com.nextcloud.talk.R @Composable -fun UnreadMessagesPopup( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { +fun UnreadMessagesPopup(onClick: () -> Unit, modifier: Modifier = Modifier) { Button( onClick = onClick, modifier = modifier, 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 598e0d78251..9f9ed1edb68 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 @@ -416,7 +416,7 @@ class ChatViewModel @AssistedInject constructor( private fun isInfoMessageAboutDeletion(currentMessage: MutableMap.MutableEntry): Boolean = currentMessage.value.parentMessageId != null && currentMessage.value.systemMessageType == ChatMessage - .SystemMessageType.MESSAGE_DELETED + .SystemMessageType.MESSAGE_DELETED private fun isReactionsMessage(currentMessage: MutableMap.MutableEntry): Boolean = currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION || @@ -429,7 +429,7 @@ class ChatViewModel @AssistedInject constructor( private fun isEditMessage(currentMessage: MutableMap.MutableEntry): Boolean = currentMessage.value.parentMessageId != null && currentMessage.value.systemMessageType == ChatMessage - .SystemMessageType.MESSAGE_EDITED + .SystemMessageType.MESSAGE_EDITED private fun isPollVotedMessage(currentMessage: MutableMap.MutableEntry): Boolean = currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.POLL_VOTED @@ -455,11 +455,10 @@ class ChatViewModel @AssistedInject constructor( return chatMessageMap.values.toList() } - fun ChatMessage.dateKey(): LocalDate { - return Instant.ofEpochMilli(timestamp * 1000L) + fun ChatMessage.dateKey(): LocalDate = + Instant.ofEpochMilli(timestamp * 1000L) .atZone(ZoneId.systemDefault()) .toLocalDate() - } fun getAvatarUrl(message: ChatMessage): String = if (this::currentUser.isInitialized) { @@ -1342,9 +1341,6 @@ class ChatViewModel @AssistedInject constructor( @AssistedFactory interface ChatViewModelFactory { - fun create( - roomToken: String, - conversationThreadId: Long? - ): ChatViewModel + 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 5e625078634..a81d996262d 100644 --- a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt @@ -51,7 +51,6 @@ import com.nextcloud.talk.R 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.ui.chat.GetNewChatView import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.preview.ComposePreviewUtils 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 05dc46d3745..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 @@ -55,7 +55,9 @@ class SharedItemsActivity : BaseActivity() { val conversationThreadId: Long? by lazy { if (intent.hasExtra(KEY_THREAD_ID)) { intent.getLongExtra(KEY_THREAD_ID, 0L) - } else null + } else { + null + } } val chatViewModel: ChatViewModel by viewModels { 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 index dff68083e29..730116a6d36 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -82,10 +82,12 @@ fun GetNewChatView( val coroutineScope = rememberCoroutineScope() val lastNewestIdRef = remember { - object { var value: String? = null } + object { + var value: String? = null + } } - // Track unread messages count + // Track unread messages count. var unreadCount by remember { mutableIntStateOf(0) } // Determine if user is at newest message @@ -312,11 +314,7 @@ fun GetNewChatView( } @Composable -fun UnreadMessagesPopup( - unreadCount: Int, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { +fun UnreadMessagesPopup(unreadCount: Int, onClick: () -> Unit, modifier: Modifier = Modifier) { Surface( onClick = onClick, shape = RoundedCornerShape(20.dp), From e60846cbe062e52907a15318677b61c9786dcd93 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 12 Feb 2026 16:47:31 +0100 Subject: [PATCH 08/19] refactoring + unread messages marker (WIP) Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 355 +++++++++--------- .../talk/chat/viewmodels/ChatViewModel.kt | 271 ++++++++----- .../data/OfflineConversationsRepository.kt | 7 + .../OfflineFirstConversationsRepository.kt | 19 + .../com/nextcloud/talk/ui/chat/ChatView.kt | 27 ++ .../database/user/CurrentUserProvider.kt | 2 + .../database/user/CurrentUserProviderImpl.kt | 2 +- 7 files changed, 408 insertions(+), 275 deletions(-) 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 f56d9bab6e8..cc80ca6018f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -140,6 +140,7 @@ import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.viewmodels.ChatViewModel +import com.nextcloud.talk.chat.viewmodels.ChatViewModel.ConversationUiState import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel import com.nextcloud.talk.contextchat.ContextChatView import com.nextcloud.talk.contextchat.ContextChatViewModel @@ -175,7 +176,6 @@ 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 @@ -244,9 +244,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -630,7 +627,16 @@ class ChatActivity : private fun setChatListContent() { binding.messagesListViewCompose.setContent { - val chatItems by chatViewModel.chatItems.collectAsStateWithLifecycle(emptyList()) + val chatItems by chatViewModel.chatItemsState.collectAsStateWithLifecycle(emptyList()) + val conversationUiState by chatViewModel.conversationUiState.collectAsStateWithLifecycle() + + 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 @@ -650,6 +656,60 @@ class ChatActivity : } } + // 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()) @@ -788,74 +848,6 @@ class ChatActivity : } } - 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 - } - } - } - - 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 -> {} - } - } - chatViewModel.getCapabilitiesViewState.observe(this) { state -> when (state) { is ChatViewModel.GetCapabilitiesUpdateState -> { @@ -878,124 +870,106 @@ class ChatActivity : } is ChatViewModel.GetCapabilitiesInitialLoadState -> { - if (currentConversation != null) { - spreedCapabilities = state.spreedCapabilities - chatApiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) - participantPermissions = ParticipantPermissions(spreedCapabilities, currentConversation!!) - - supportFragmentManager.commit { - setReorderingAllowed(true) // optimizes out redundant replace operations - replace(R.id.fragment_container_activity_chat, messageInputFragment) - runOnCommit { - if (focusInput) { - messageInputFragment.binding.fragmentMessageInputView.requestFocus() - } + spreedCapabilities = state.spreedCapabilities + currentConversation = state.conversationModel + chatApiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) + participantPermissions = ParticipantPermissions(spreedCapabilities, state.conversationModel!!) + + supportFragmentManager.commit { + setReorderingAllowed(true) // optimizes out redundant replace operations + replace(R.id.fragment_container_activity_chat, messageInputFragment) + runOnCommit { + if (focusInput) { + messageInputFragment.binding.fragmentMessageInputView.requestFocus() } } + } - joinRoomWithPassword() - - 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() - if (currentConversation?.objectType == ConversationEnums.ObjectType.EVENT && - hasSpreedFeatureCapability( - conversationUser?.capabilities!!.spreedCapability!!, - SpreedFeatures.UNBIND_CONVERSATION + loadAvatarForStatusBar() + setupSwipeToReply() + setActionBarTitle() + isEventConversation() + checkShowCallButtons() + checkLobbyState() + if (state.conversationModel.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL && + state.conversationModel.status == "dnd" + ) { + conversationUser.let { user -> + val credentials = ApiUtils.getCredentials(user.username, user.token) + chatViewModel.outOfOfficeStatusOfUser( + credentials!!, + user.baseUrl!!, + state.conversationModel!!.name ) - ) { - val eventEndTimeStamp = - currentConversation?.objectId - ?.split("#") - ?.getOrNull(1) - ?.toLongOrNull() - val currentTimeStamp = (System.currentTimeMillis() / ONE_SECOND_IN_MILLIS).toLong() - val retentionPeriod = retentionOfEventRooms(spreedCapabilities) - val isPastEvent = eventEndTimeStamp?.let { it < currentTimeStamp } - if (isPastEvent == true && retentionPeriod != 0) { - showConversationDeletionWarning(retentionPeriod) - } } + } - if (currentConversation?.objectType == ConversationEnums.ObjectType.PHONE_TEMPORARY && - hasSpreedFeatureCapability( - conversationUser?.capabilities!!.spreedCapability!!, - SpreedFeatures.UNBIND_CONVERSATION - ) - ) { - val retentionPeriod = retentionOfSIPRoom(spreedCapabilities) - val systemMessage = currentConversation?.lastMessage?.systemMessageType - if (retentionPeriod != 0 && - ( - systemMessage == ChatMessage.SystemMessageType.CALL_ENDED || - systemMessage == ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE - ) - ) { - showConversationDeletionWarning(retentionPeriod) - } + if (state.conversationModel.objectType == ConversationEnums.ObjectType.EVENT && + hasSpreedFeatureCapability( + conversationUser?.capabilities!!.spreedCapability!!, + SpreedFeatures.UNBIND_CONVERSATION + ) + ) { + val eventEndTimeStamp = + state.conversationModel?.objectId + ?.split("#") + ?.getOrNull(1) + ?.toLongOrNull() + val currentTimeStamp = (System.currentTimeMillis() / ONE_SECOND_IN_MILLIS).toLong() + val retentionPeriod = retentionOfEventRooms(spreedCapabilities) + val isPastEvent = eventEndTimeStamp?.let { it < currentTimeStamp } + if (isPastEvent == true && retentionPeriod != 0) { + showConversationDeletionWarning(retentionPeriod) } + } - if (currentConversation?.objectType == ConversationEnums.ObjectType.INSTANT_MEETING && - hasSpreedFeatureCapability( - conversationUser?.capabilities!!.spreedCapability!!, - SpreedFeatures.UNBIND_CONVERSATION - ) + if (state.conversationModel.objectType == ConversationEnums.ObjectType.PHONE_TEMPORARY && + hasSpreedFeatureCapability( + conversationUser?.capabilities!!.spreedCapability!!, + SpreedFeatures.UNBIND_CONVERSATION + ) + ) { + val retentionPeriod = retentionOfSIPRoom(spreedCapabilities) + val systemMessage = currentConversation?.lastMessage?.systemMessageType + if (retentionPeriod != 0 && + ( + systemMessage == ChatMessage.SystemMessageType.CALL_ENDED || + systemMessage == ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE + ) ) { - val retentionPeriod = retentionOfInstantMeetingRoom(spreedCapabilities) - val systemMessage = currentConversation?.lastMessage?.systemMessageType - if (retentionPeriod != 0 && - ( - systemMessage == ChatMessage.SystemMessageType.CALL_ENDED || - systemMessage == ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE - ) - ) { - showConversationDeletionWarning(retentionPeriod) - } + showConversationDeletionWarning(retentionPeriod) } + } - updateRoomTimerHandler(MILLIS_250) - - val urlForChatting = - ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken) - - lifecycleScope.launch { - chatViewModel.loadInitialMessages( - withCredentials = credentials!!, - withUrl = urlForChatting, - hasHighPerformanceBackend = - WebSocketConnectionHelper.getWebSocketInstanceForUser(conversationUser) != null - ) - } - } 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 -> { @@ -1456,11 +1430,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) { @@ -2894,7 +2871,7 @@ class ChatActivity : // 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? - updateRemoteLastReadMessageIfNeeded() + // updateRemoteLastReadMessageIfNeeded() adapter = null } @@ -2917,16 +2894,20 @@ class ChatActivity : } private fun updateRemoteLastReadMessageIfNeeded() { - val url = ApiUtils.getUrlForChatReadMarker( - ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(ApiUtils.API_V1)), - conversationUser.baseUrl!!, - roomToken - ) + 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 - ) + chatViewModel.updateRemoteLastReadMessageIfNeeded( + credentials = credentials!!, + url = url + ) + } + } } private fun isActivityNotChangingConfigurations(): Boolean = !isChangingConfigurations @@ -3382,7 +3363,7 @@ class ChatActivity : DateFormatter.isSameDay(message1.createdAt, message2.createdAt) private fun loadMoreMessagesCompose() { - val currentItems = chatViewModel.chatItems.value + val currentItems = chatViewModel.chatItemsState.value val messageId = currentItems .asReversed() 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 9f9ed1edb68..52fa543d2b4 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 @@ -26,6 +26,7 @@ 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.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.model.ChatMessageEntity @@ -54,6 +55,7 @@ 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 @@ -61,7 +63,6 @@ import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -69,15 +70,19 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import java.io.File import java.time.Instant @@ -111,19 +116,27 @@ class ChatViewModel @AssistedInject constructor( STOPPED } + @Deprecated("use currentUserFlow") lateinit var currentUser: User private var localLastReadMessage: Int = 0 private lateinit var currentConversation: ConversationModel + 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 - private val internalConversationId = MutableStateFlow(null) + @Deprecated("chatkit...") + private val internalConversationId: Flow = + currentUserProvider.currentUserFlow.map { user -> + "${user.id}@$chatRoomToken" + } + var messageDraft: MessageDraft = MessageDraft() lateinit var participantPermissions: ParticipantPermissions @@ -163,6 +176,10 @@ class ChatViewModel @AssistedInject constructor( } } + fun setUnreadMessagesMarker(shouldShow: Boolean) { + showUnreadMessagesMarker = shouldShow + } + val backgroundPlayUIFlow = mediaPlayerManager.backgroundPlayUIFlow val mediaPlayerSeekbarObserver: Flow @@ -220,14 +237,13 @@ class ChatViewModel @AssistedInject constructor( val getLastReadMessageFlow = chatRepository.lastReadMessageFlow - val getConversationFlow = conversationRepository.conversationFlow - .onEach { - _getRoomViewState.value = GetRoomSuccessState - }.catch { - _getRoomViewState.value = GetRoomErrorState - } - - // val getGeneralUIFlow = chatRepository.generalUIFlow + // val getConversationFlow = conversationRepository.conversationFlow + // .onEach { + // currentConversation = it + // _getRoomViewState.value = GetRoomSuccessState + // }.catch { + // _getRoomViewState.value = GetRoomErrorState + // } sealed interface ViewState @@ -240,17 +256,26 @@ class ChatViewModel @AssistedInject constructor( val getReminderExistState: LiveData get() = _getReminderExistState - object GetRoomStartState : ViewState - object GetRoomErrorState : ViewState - object GetRoomSuccessState : ViewState + // object GetRoomStartState : ViewState + // object GetRoomErrorState : ViewState + // object GetRoomSuccessState : ViewState + + sealed interface ConversationUiState { + object Loading : ConversationUiState + object Empty : ConversationUiState + data class Success(val data: ConversationModel) : ConversationUiState + } - private val _getRoomViewState: MutableLiveData = MutableLiveData(GetRoomStartState) - val getRoomViewState: LiveData - get() = _getRoomViewState + // 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) @@ -323,74 +348,144 @@ class ChatViewModel @AssistedInject constructor( val reactionDeletedViewState: LiveData get() = _reactionDeletedViewState - init { - viewModelScope.launch { - currentUserProvider.getCurrentUser() - .onSuccess { user -> - internalConversationId.value = currentUser.id.toString() + "@" + chatRoomToken - } - } - } + private val currentUserFlow: StateFlow = + currentUserProvider.currentUserFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, null) - @OptIn(ExperimentalCoroutinesApi::class) - val chatItems: StateFlow> = - internalConversationId - .filterNotNull() - .flatMapLatest { conversationId -> - chatRepository.observeMessages(conversationId) - } - .map { entities -> - entities.map(ChatMessageEntity::asModel) - } - .onEach { messages -> - messages.forEach { - it.avatarUrl = getAvatarUrl(it) - it.incoming = it.actorId != currentUser.userId - } - } - .map { messages -> - messages - .let(::handleSystemMessages) - .let(::handleThreadMessages) + private val nonNullUserFlow = + currentUserFlow.filterNotNull() + + val conversationUiState: StateFlow = + nonNullUserFlow + .flatMapLatest { user -> + val userId = requireNotNull(user.id) + conversationRepository.observeConversation(userId, chatRoomToken) } - .map { messages -> - buildList { - var lastDate: LocalDate? = null - messages.asReversed().forEach { msg -> - val date = msg.dateKey() - if (date != lastDate) { - add(ChatItem.DateHeaderItem(date)) - lastDate = date - } - add(ChatItem.MessageItem(msg)) + .map { result -> + when (result) { + is OfflineFirstConversationsRepository.ConversationResult.Found -> { + this.currentConversation = result.conversation + ConversationUiState.Success(result.conversation) } - }.asReversed() + + OfflineFirstConversationsRepository.ConversationResult.NotFound -> + ConversationUiState.Empty + } } .stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = emptyList() + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + ConversationUiState.Loading ) - @OptIn(ExperimentalCoroutinesApi::class) - val messagesForChatKit: StateFlow> = - internalConversationId - .filterNotNull() - .flatMapLatest { conversationId -> - chatRepository.observeMessages(conversationId) + val conversationFlow: Flow = + conversationUiState.mapNotNull { + (it as? ConversationUiState.Success)?.data + } + + private val conversationAndUserFlow = + combine(conversationFlow, nonNullUserFlow) { c, u -> c to u } + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), replay = 1) + + private fun Flow>.mapToChatMessages( + userId: String + ): Flow> = + map { entities -> + entities.map(ChatMessageEntity::asModel) + .onEach { msg -> + msg.avatarUrl = getAvatarUrl(msg) + msg.incoming = msg.actorId != userId + } + } + + val messagesFlow: Flow> = + conversationAndUserFlow + .flatMapLatest { (conversation, user) -> + chatRepository + .observeMessages(conversation.internalId) + .mapToChatMessages(user.userId!!) } - .map { entities -> entities.map(ChatMessageEntity::asModel) } - .onEach { messages -> - messages.forEach { - it.avatarUrl = getAvatarUrl(it) - it.incoming = it.actorId != currentUser.userId + + val chatItemsState: StateFlow> = + combine(messagesFlow, conversationFlow) { messages, conversation -> + buildChatItems(messages, conversation) + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + private fun buildChatItems( + messages: List, + conversation: ConversationModel + ): List { + var lastDate: LocalDate? = null + + return buildList { + for (msg in messages.asReversed()) { + val date = msg.dateKey() + + if (date != lastDate) { + add(ChatItem.DateHeaderItem(date)) + lastDate = date + } + + add(ChatItem.MessageItem(msg)) + + if (showUnreadMessagesMarker && + msg.jsonMessageId == conversation.lastReadMessage + ) { + add(ChatItem.UnreadMessagesMarkerItem(date)) } } - .stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = emptyList() - ) + }.asReversed() + } + + val messagesForChatKit: StateFlow> = + conversationAndUserFlow + .flatMapLatest { (conversation, user) -> + chatRepository + .observeMessages(conversation.internalId) + .mapToChatMessages(user.userId!!) + } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + fun observeConversationAndUserFirstTime() { + conversationAndUserFlow + .take(1) + .onEach { (conversation, user) -> + val credentials = + ApiUtils.getCredentials(user.username, user.token) ?: return@onEach + + val url = + ApiUtils.getUrlForChat(1, user.baseUrl, chatRoomToken) + + chatRepository.updateConversation(conversation) + + loadInitialMessages( + withCredentials = credentials, + withUrl = url, + hasHighPerformanceBackend = + WebSocketConnectionHelper + .getWebSocketInstanceForUser(user) != null + ) + + getCapabilities(user, chatRoomToken, conversation) + } + .launchIn(viewModelScope) + } + + 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 = @@ -481,17 +576,14 @@ class ChatViewModel @AssistedInject constructor( chatRoomToken, threadId ) - } - - fun updateConversation(currentConversation: ConversationModel) { - this.currentConversation = currentConversation - chatRepository.updateConversation(currentConversation) - advanceLocalLastReadMessageIfNeeded(currentConversation.lastReadMessage) + observeConversationAndUserFirstTime() + observeConversationAndUserEveryTime() } + @Deprecated("use observeConversation") fun getRoom(token: String) { - _getRoomViewState.value = GetRoomStartState + // _getRoomViewState.value = GetRoomStartState conversationRepository.getRoom(currentUser, token) } @@ -515,7 +607,8 @@ class ChatViewModel @AssistedInject 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!!) @@ -535,7 +628,10 @@ class ChatViewModel @AssistedInject constructor( override fun onNext(spreedCapabilities: SpreedCapability) { if (_getCapabilitiesViewState.value == GetCapabilitiesStartState) { - _getCapabilitiesViewState.value = GetCapabilitiesInitialLoadState(spreedCapabilities) + _getCapabilitiesViewState.value = GetCapabilitiesInitialLoadState( + spreedCapabilities, + conversationModel + ) } else { _getCapabilitiesViewState.value = GetCapabilitiesUpdateState(spreedCapabilities) } @@ -628,7 +724,6 @@ class ChatViewModel @AssistedInject constructor( override fun onNext(t: GenericOverall) { _leaveRoomViewState.value = LeaveRoomSuccessState(funToCallWhenLeaveSuccessful) _getCapabilitiesViewState.value = GetCapabilitiesStartState - _getRoomViewState.value = GetRoomStartState } }) } @@ -1333,10 +1428,12 @@ class ChatViewModel @AssistedInject constructor( when (this) { is MessageItem -> "msg_${message.id}" is DateHeaderItem -> "header_$date" + is UnreadMessagesMarkerItem -> "last_read_$date" } data class MessageItem(val message: ChatMessage) : ChatItem data class DateHeaderItem(val date: LocalDate) : ChatItem + data class UnreadMessagesMarkerItem(val date: LocalDate) : ChatItem } @AssistedFactory diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt index a9f80c0189a..2e10573ee9b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt @@ -7,6 +7,7 @@ package com.nextcloud.talk.conversationlist.data +import com.nextcloud.talk.conversationlist.data.network.OfflineFirstConversationsRepository.ConversationResult import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.ConversationModel import kotlinx.coroutines.Job @@ -22,6 +23,7 @@ interface OfflineConversationsRepository { /** * Stream of a single conversation, for use in each conversations settings. */ + @Deprecated("use observeConversation") val conversationFlow: Flow /** @@ -30,15 +32,20 @@ interface OfflineConversationsRepository { * emits to [roomListFlow] if the rooms list is not empty. * */ + @Deprecated("use observeConversation") fun getRooms(user: User): Job /** * Called once onStart to emit a conversation to [conversationFlow] * to be handled asynchronously. */ + @Deprecated("use observeConversation") fun getRoom(user: User, roomToken: String): Job suspend fun updateConversation(conversationModel: ConversationModel) + @Deprecated("use observeConversation") suspend fun getLocallyStoredConversation(user: User, roomToken: String): ConversationModel? + + fun observeConversation(accountId: Long, roomToken: String): Flow } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt index 84b9e2c0045..817a526373a 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 @@ -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.asModel()) + } + } + override fun getRooms(user: User): Job = scope.launch { val initialConversationModels = getListOfConversations(user.id!!) 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 index 730116a6d36..d9ef6c4a2a4 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -178,6 +178,9 @@ fun GetNewChatView( is ChatViewModel.ChatItem.DateHeaderItem -> formatTime(item.date) + + is ChatViewModel.ChatItem.UnreadMessagesMarkerItem -> + formatTime(item.date) } } ?: "" } @@ -248,6 +251,10 @@ fun GetNewChatView( is ChatViewModel.ChatItem.DateHeaderItem -> { DateHeader(chatItem.date) } + + is ChatViewModel.ChatItem.UnreadMessagesMarkerItem -> { + UnreadMessagesMarker(chatItem.date) + } } } } @@ -354,6 +361,26 @@ fun DateHeader(date: LocalDate) { } } +@Composable +fun UnreadMessagesMarker(date: LocalDate) { + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Unread messages", + modifier = Modifier + .background( + Color.Gray.copy(alpha = 0.2f), + RoundedCornerShape(12.dp) + ) + .padding(horizontal = 12.dp, vertical = 6.dp), + fontSize = 12.sp + ) + } +} + @Deprecated("do not use Compose Chat Adapter") @OptIn(ExperimentalFoundationApi::class) @Composable 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 { From 7513d66c8801e2d652caae09acca5da2cba1d2f5 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 13 Feb 2026 09:01:20 +0100 Subject: [PATCH 09/19] add new showUnreadMessagesMarker logic Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 5 +- .../talk/chat/MessageInputFragment.kt | 3 + .../talk/chat/data/model/ChatMessage.kt | 4 +- .../talk/chat/viewmodels/ChatViewModel.kt | 93 +++++++++++++++++-- .../com/nextcloud/talk/ui/chat/Message.kt | 5 +- 5 files changed, 96 insertions(+), 14 deletions(-) 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 cc80ca6018f..20435ea4fca 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2870,8 +2870,9 @@ class ChatActivity : // 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? - // updateRemoteLastReadMessageIfNeeded() + // -> save this field offline in conversation. when getting new conversations, do not overwrite + // lastReadMessage if offline has higher value + updateRemoteLastReadMessageIfNeeded() adapter = null } diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index 44d92b58cbc..7429039df6f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -1001,6 +1001,9 @@ 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/data/model/ChatMessage.kt b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt index ba5655d111c..6a371aef4bd 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 @@ -146,7 +146,9 @@ data class ChatMessage( var sendAt: Int? = null, - var avatarUrl: String? = null + var avatarUrl: String? = null, + + var isUnread: Boolean = false, ) : MessageContentType, MessageContentType.Image { 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 52fa543d2b4..ac97a02d075 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 @@ -71,6 +71,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest @@ -89,6 +91,7 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId import javax.inject.Inject +import kotlin.collections.map @Suppress("TooManyFunctions", "LongParameterList") class ChatViewModel @AssistedInject constructor( @@ -348,6 +351,18 @@ class ChatViewModel @AssistedInject constructor( val reactionDeletedViewState: LiveData get() = _reactionDeletedViewState + private var firstUnreadMessageId: Int? = null + private var oneOrMoreMessagesWereSent: Boolean = false + + init { + println("ChatVM created ${hashCode()}") + } + + override fun onCleared() { + println("ChatVM cleared ${hashCode()}") + } + + private val currentUserFlow: StateFlow = currentUserProvider.currentUserFlow .stateIn(viewModelScope, SharingStarted.Eagerly, null) @@ -378,11 +393,34 @@ class ChatViewModel @AssistedInject constructor( ConversationUiState.Loading ) - val conversationFlow: Flow = + var previous: ConversationModel? = null + + val conversationFlow = + // conversationUiState + // .mapNotNull { (it as? ConversationUiState.Success)?.data } + // .distinctUntilChanged { old, new -> + // old.uiEquals(new) + // } + // .onEach { new -> + // previous?.let { old -> + // println("Conversation changed") + // println("OLD: $old") + // println("NEW: $new") + // } + // previous = new + // } conversationUiState.mapNotNull { (it as? ConversationUiState.Success)?.data + }.distinctUntilChangedBy { + it.lastReadMessage + }.onEach { + println("Conversation changed: lastRead=${it.lastReadMessage}") } + fun ConversationModel.uiEquals(other: ConversationModel): Boolean { + return lastReadMessage == other.lastReadMessage + } + private val conversationAndUserFlow = combine(conversationFlow, nonNullUserFlow) { c, u -> c to u } .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), replay = 1) @@ -403,13 +441,34 @@ class ChatViewModel @AssistedInject constructor( .flatMapLatest { (conversation, user) -> chatRepository .observeMessages(conversation.internalId) + .distinctUntilChanged() .mapToChatMessages(user.userId!!) } + .map { messages -> messages + .let(::handleSystemMessages) + .let(::handleThreadMessages) + } + .distinctUntilChangedBy { messages -> + messages.map { it.jsonMessageId } + } val chatItemsState: StateFlow> = - combine(messagesFlow, conversationFlow) { messages, conversation -> - buildChatItems(messages, conversation) - }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + combine( + messagesFlow, + conversationFlow + ) { messages, conversation -> + if (messages.isEmpty()) return@combine emptyList() + buildChatItems( + messages, + conversation + ) + } + .distinctUntilChanged() + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + emptyList() + ) private fun buildChatItems( messages: List, @@ -418,7 +477,18 @@ class ChatViewModel @AssistedInject constructor( var lastDate: LocalDate? = null return buildList { - for (msg in messages.asReversed()) { + val reversedMessages = messages.asReversed() + if (firstUnreadMessageId == null) { + firstUnreadMessageId = + reversedMessages.firstOrNull { + it.jsonMessageId > conversation.lastReadMessage + }?.jsonMessageId + Log.d(TAG, "reversedMessages.size = ${reversedMessages.size}") + Log.d(TAG, "firstUnreadMessageId = $firstUnreadMessageId") + Log.d(TAG, "conversation.lastReadMessage = ${conversation.lastReadMessage}") + } + + for (msg in reversedMessages) { val date = msg.dateKey() if (date != lastDate) { @@ -426,17 +496,20 @@ class ChatViewModel @AssistedInject constructor( lastDate = date } - add(ChatItem.MessageItem(msg)) - - if (showUnreadMessagesMarker && - msg.jsonMessageId == conversation.lastReadMessage - ) { + if (!oneOrMoreMessagesWereSent && msg.jsonMessageId == firstUnreadMessageId) { add(ChatItem.UnreadMessagesMarkerItem(date)) } + + add(ChatItem.MessageItem(msg)) } + }.asReversed() } + fun onMessageSent() { + oneOrMoreMessagesWereSent = true + } + val messagesForChatKit: StateFlow> = conversationAndUserFlow .flatMapLatest { (conversation, user) -> diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt index b83355666be..bf3a6ade3d1 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt @@ -337,7 +337,10 @@ fun EnrichedText(message: ChatMessage) { ) setLineSpacing(0F, LINE_SPACING) textAlignment = View.TEXT_ALIGNMENT_VIEW_START - text = processedMessageText + // text = processedMessageText + // added messageId just for debugging + text = "" + processedMessageText + " (" + message.jsonMessageId + ")" + setPadding(0, INT_8, 0, 0) } }, modifier = Modifier) From 1e932cc6f9c9ead442ce80f53e40611ee72ea99c Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 17 Feb 2026 16:02:26 +0100 Subject: [PATCH 10/19] refactor flows Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 57 ++--- .../talk/chat/viewmodels/ChatViewModel.kt | 226 ++++++++++-------- .../com/nextcloud/talk/ui/chat/ChatMessage.kt | 115 +++++++++ .../com/nextcloud/talk/ui/chat/ChatView.kt | 95 +------- .../com/nextcloud/talk/ui/chat/Message.kt | 3 +- .../com/nextcloud/talk/ui/chat/TextMessage.kt | 7 +- 6 files changed, 268 insertions(+), 235 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessage.kt 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 20435ea4fca..d38d1522cc8 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -140,7 +140,6 @@ import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.viewmodels.ChatViewModel -import com.nextcloud.talk.chat.viewmodels.ChatViewModel.ConversationUiState import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel import com.nextcloud.talk.contextchat.ContextChatView import com.nextcloud.talk.contextchat.ContextChatViewModel @@ -627,16 +626,18 @@ class ChatActivity : private fun setChatListContent() { binding.messagesListViewCompose.setContent { - val chatItems by chatViewModel.chatItemsState.collectAsStateWithLifecycle(emptyList()) - val conversationUiState by chatViewModel.conversationUiState.collectAsStateWithLifecycle() - - when (conversationUiState) { - ConversationUiState.Loading -> {} - ConversationUiState.Empty -> {} - is ConversationUiState.Success -> { - currentConversation = (conversationUiState as ConversationUiState.Success).data - } - } + 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 @@ -645,8 +646,12 @@ class ChatActivity : LocalViewThemeUtils provides viewThemeUtils, LocalMessageUtils provides messageUtils ) { + val showAvatar = !isOneToOneConversation() + Log.d(TAG,"showAvatar="+ showAvatar) + GetNewChatView( - chatItems = chatItems, + chatItems = uiState.items, + showAvatar = showAvatar, conversationThreadId = conversationThreadId, onLoadMore = { loadMoreMessagesCompose() }, advanceLocalLastReadMessageIfNeeded = { advanceLocalLastReadMessageIfNeeded(it) }, @@ -3364,33 +3369,7 @@ class ChatActivity : DateFormatter.isSameDay(message1.createdAt, message2.createdAt) private fun loadMoreMessagesCompose() { - val currentItems = chatViewModel.chatItemsState.value - - val messageId = currentItems - .asReversed() - .firstNotNullOfOrNull { item -> - (item as? ChatViewModel.ChatItem.MessageItem) - ?.message - ?.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 - ) - } + chatViewModel.loadMoreMessagesCompose() } // private fun loadMoreMessagesCompose() { 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 ac97a02d075..7a9e6535393 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 @@ -85,13 +85,13 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.io.File import java.time.Instant import java.time.LocalDate import java.time.ZoneId import javax.inject.Inject -import kotlin.collections.map @Suppress("TooManyFunctions", "LongParameterList") class ChatViewModel @AssistedInject constructor( @@ -124,8 +124,6 @@ class ChatViewModel @AssistedInject constructor( private var localLastReadMessage: Int = 0 - private lateinit var currentConversation: ConversationModel - private var showUnreadMessagesMarker: Boolean = true private val mediaPlayerManager: MediaPlayerManager = MediaPlayerManager.sharedInstance(appPreferences) @@ -232,21 +230,12 @@ class ChatViewModel @AssistedInject constructor( private val _events = MutableSharedFlow() val events = _events.asSharedFlow() - // val getRemoveMessageFlow = chatRepository.removeMessageFlow - val getUpdateMessageFlow = chatRepository.updateMessageFlow val getLastCommonReadFlow = chatRepository.lastCommonReadFlow val getLastReadMessageFlow = chatRepository.lastReadMessageFlow - // val getConversationFlow = conversationRepository.conversationFlow - // .onEach { - // currentConversation = it - // _getRoomViewState.value = GetRoomSuccessState - // }.catch { - // _getRoomViewState.value = GetRoomErrorState - // } sealed interface ViewState @@ -259,20 +248,6 @@ class ChatViewModel @AssistedInject constructor( val getReminderExistState: LiveData get() = _getReminderExistState - // object GetRoomStartState : ViewState - // object GetRoomErrorState : ViewState - // object GetRoomSuccessState : ViewState - - sealed interface ConversationUiState { - object Loading : ConversationUiState - object Empty : ConversationUiState - data class Success(val data: ConversationModel) : ConversationUiState - } - - // private val _getRoomViewState: MutableLiveData = MutableLiveData(GetRoomStartState) - // val getRoomViewState: LiveData - // get() = _getRoomViewState - object GetCapabilitiesStartState : ViewState object GetCapabilitiesErrorState : ViewState open class GetCapabilitiesInitialLoadState( @@ -352,82 +327,63 @@ class ChatViewModel @AssistedInject constructor( get() = _reactionDeletedViewState private var firstUnreadMessageId: Int? = null - private var oneOrMoreMessagesWereSent: Boolean = false - init { - println("ChatVM created ${hashCode()}") - } - override fun onCleared() { - println("ChatVM cleared ${hashCode()}") - } + private var oneOrMoreMessagesWereSent = false + + // ------------------------------ + // UI State. This should be the only UI state. Add more val here and update via copy whenever necessary. + // ------------------------------ + data class ChatUiState( + val items: List = emptyList(), + val showChatAvatars: Boolean = true, + // 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 nonNullUserFlow = currentUserFlow.filterNotNull() - val conversationUiState: StateFlow = + private val conversationFlow: Flow = nonNullUserFlow .flatMapLatest { user -> val userId = requireNotNull(user.id) conversationRepository.observeConversation(userId, chatRoomToken) } - .map { result -> + .mapNotNull { result -> when (result) { - is OfflineFirstConversationsRepository.ConversationResult.Found -> { - this.currentConversation = result.conversation - ConversationUiState.Success(result.conversation) - } + is OfflineFirstConversationsRepository.ConversationResult.Found -> + result.conversation OfflineFirstConversationsRepository.ConversationResult.NotFound -> - ConversationUiState.Empty + null } } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5_000), - ConversationUiState.Loading - ) - - var previous: ConversationModel? = null - - val conversationFlow = - // conversationUiState - // .mapNotNull { (it as? ConversationUiState.Success)?.data } - // .distinctUntilChanged { old, new -> - // old.uiEquals(new) - // } - // .onEach { new -> - // previous?.let { old -> - // println("Conversation changed") - // println("OLD: $old") - // println("NEW: $new") - // } - // previous = new - // } - conversationUiState.mapNotNull { - (it as? ConversationUiState.Success)?.data - }.distinctUntilChangedBy { - it.lastReadMessage - }.onEach { - println("Conversation changed: lastRead=${it.lastReadMessage}") - } - - fun ConversationModel.uiEquals(other: ConversationModel): Boolean { - return lastReadMessage == other.lastReadMessage - } + .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) - private fun Flow>.mapToChatMessages( - userId: String - ): Flow> = + // ------------------------------ + // Messages + // ------------------------------ + private fun Flow>.mapToChatMessages(userId: String): Flow> = map { entities -> entities.map(ChatMessageEntity::asModel) .onEach { msg -> @@ -436,7 +392,7 @@ class ChatViewModel @AssistedInject constructor( } } - val messagesFlow: Flow> = + private val messagesFlow: Flow> = conversationAndUserFlow .flatMapLatest { (conversation, user) -> chatRepository @@ -444,35 +400,62 @@ class ChatViewModel @AssistedInject constructor( .distinctUntilChanged() .mapToChatMessages(user.userId!!) } - .map { messages -> messages - .let(::handleSystemMessages) + .map { messages -> + messages.let(::handleSystemMessages) .let(::handleThreadMessages) } - .distinctUntilChangedBy { messages -> - messages.map { it.jsonMessageId } + .distinctUntilChangedBy { it.map { msg -> msg.jsonMessageId } } + + // ------------------------------ + // Last read message cache + // ------------------------------ + private var lastReadMessage: Int = 0 + + // ------------------------------ + // Initialization + // ------------------------------ + init { + observeConversation() + observeMessages() + } + + // ------------------------------ + // Observe conversation + // ------------------------------ + private fun observeConversation() { + conversationFlow + .onEach { conversation -> + lastReadMessage = conversation.lastReadMessage + + _uiState.update { current -> + current.copy( + conversation = conversation + ) + } } + .launchIn(viewModelScope) + } - val chatItemsState: StateFlow> = - combine( - messagesFlow, - conversationFlow - ) { messages, conversation -> - if (messages.isEmpty()) return@combine emptyList() - buildChatItems( - messages, - conversation - ) - } - .distinctUntilChanged() - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5_000), - emptyList() - ) + // ------------------------------ + // Observe messages + // ------------------------------ + private fun observeMessages() { + messagesFlow + .onEach { messages -> + val items = buildChatItems(messages, lastReadMessage) + _uiState.update { current -> + current.copy(items = items) + } + } + .launchIn(viewModelScope) + } + // ------------------------------ + // Build chat items (pure) + // ------------------------------ private fun buildChatItems( messages: List, - conversation: ConversationModel + lastReadMessage: Int ): List { var lastDate: LocalDate? = null @@ -481,11 +464,11 @@ class ChatViewModel @AssistedInject constructor( if (firstUnreadMessageId == null) { firstUnreadMessageId = reversedMessages.firstOrNull { - it.jsonMessageId > conversation.lastReadMessage + it.jsonMessageId > lastReadMessage }?.jsonMessageId Log.d(TAG, "reversedMessages.size = ${reversedMessages.size}") Log.d(TAG, "firstUnreadMessageId = $firstUnreadMessageId") - Log.d(TAG, "conversation.lastReadMessage = ${conversation.lastReadMessage}") + Log.d(TAG, "conversation.lastReadMessage = ${lastReadMessage}") } for (msg in reversedMessages) { @@ -510,6 +493,7 @@ class ChatViewModel @AssistedInject constructor( oneOrMoreMessagesWereSent = true } + @Deprecated("use messagesFlow") val messagesForChatKit: StateFlow> = conversationAndUserFlow .flatMapLatest { (conversation, user) -> @@ -879,6 +863,40 @@ class ChatViewModel @AssistedInject constructor( chatRepository.startMessagePolling(hasHighPerformanceBackend) } + fun loadMoreMessagesCompose() { + val currentItems = _uiState.value.items + + val messageId = currentItems + .asReversed() + .firstNotNullOfOrNull { item -> + (item as? ChatItem.MessageItem) + ?.message + ?.jsonMessageId + } + + 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( beforeMessageId: Long, roomToken: String, @@ -945,7 +963,7 @@ class ChatViewModel @AssistedInject constructor( * Please use with caution to not spam the server */ fun updateRemoteLastReadMessageIfNeeded(credentials: String, url: String) { - if (localLastReadMessage > currentConversation.lastReadMessage) { + if (localLastReadMessage > _uiState.value.conversation!!.lastReadMessage) { setChatReadMessage(credentials, url, localLastReadMessage) } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessage.kt new file mode 100644 index 00000000000..340f26d437b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessage.kt @@ -0,0 +1,115 @@ +/* + * 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.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.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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.data.model.ChatMessage + + +@Composable +fun ChatMessage( + message: ChatMessage, + showAvatar: Boolean, + conversationThreadId: Long? = null, + isBlinkingState: MutableState = mutableStateOf(false) +) { + when (message.getCalculateMessageType()) { + ChatMessage.MessageType.REGULAR_TEXT_MESSAGE -> { + if (message.isLinkPreview()) { + LinkMessage( + message = message, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } else { + TextMessage( + message = message, + showAvatar = showAvatar, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } + } + + ChatMessage.MessageType.SYSTEM_MESSAGE -> { + if (!message.shouldFilter()) { + SystemMessage(message) + } + } + + ChatMessage.MessageType.VOICE_MESSAGE -> { + VoiceMessage( + message = message, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } + + ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE -> { + ImageMessage( + message = message, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } + + ChatMessage.MessageType.SINGLE_NC_GEOLOCATION_MESSAGE -> { + GeolocationMessage( + message = message, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } + + ChatMessage.MessageType.POLL_MESSAGE -> { + PollMessage( + message = message, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } + + ChatMessage.MessageType.DECK_CARD -> { + DeckMessage( + message = message, + conversationThreadId = conversationThreadId, + state = isBlinkingState + ) + } + + else -> { + Log.d("ChatView", "Unknown message type: ${'$'}type") + } + } +} + +private fun ChatMessage.shouldFilter(): Boolean = + systemMessageType in setOf( + ChatMessage.SystemMessageType.REACTION, + ChatMessage.SystemMessageType.REACTION_DELETED, + ChatMessage.SystemMessageType.REACTION_REVOKED, + ChatMessage.SystemMessageType.POLL_VOTED, + ChatMessage.SystemMessageType.MESSAGE_EDITED, + ChatMessage.SystemMessageType.THREAD_CREATED + ) || + (parentMessageId != null && systemMessageType == ChatMessage.SystemMessageType.MESSAGE_DELETED) 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 index d9ef6c4a2a4..a28045adf76 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -1,6 +1,5 @@ package com.nextcloud.talk.ui.chat -import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -33,7 +32,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -72,6 +70,7 @@ private val AUTHOR_TEXT_SIZE = 12.sp @Composable fun GetNewChatView( chatItems: List, + showAvatar: Boolean, conversationThreadId: Long? = null, onLoadMore: (() -> Unit?)?, advanceLocalLastReadMessageIfNeeded: ((Int) -> Unit?)?, @@ -241,8 +240,9 @@ fun GetNewChatView( when (chatItem) { is ChatViewModel.ChatItem.MessageItem -> { val isBlinkingState = remember { mutableStateOf(false) } - GetComposableForMessage( + ChatMessage( message = chatItem.message, + showAvatar = showAvatar, conversationThreadId = conversationThreadId, isBlinkingState = isBlinkingState ) @@ -435,100 +435,15 @@ fun GetView(messages: List, messageIdToBlink: String, user: User?) items(messages) { message -> val incoming = message.actorId != user?.userId - GetComposableForMessage( + ChatMessage( message = message, + showAvatar = true, isBlinkingState = isBlinkingState ) } } } -@Composable -fun GetComposableForMessage( - message: ChatMessage, - conversationThreadId: Long? = null, - isBlinkingState: MutableState = mutableStateOf(false) -) { - when (val type = message.getCalculateMessageType()) { - ChatMessage.MessageType.REGULAR_TEXT_MESSAGE -> { - if (message.isLinkPreview()) { - LinkMessage( - message = message, - conversationThreadId = conversationThreadId, - state = isBlinkingState - ) - } else { - TextMessage( - message = message, - conversationThreadId = conversationThreadId, - state = isBlinkingState - ) - } - } - - ChatMessage.MessageType.SYSTEM_MESSAGE -> { - if (!message.shouldFilter()) { - SystemMessage(message) - } - } - - ChatMessage.MessageType.VOICE_MESSAGE -> { - VoiceMessage( - message = message, - conversationThreadId = conversationThreadId, - state = isBlinkingState - ) - } - - ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE -> { - ImageMessage( - message = message, - conversationThreadId = conversationThreadId, - state = isBlinkingState - ) - } - - ChatMessage.MessageType.SINGLE_NC_GEOLOCATION_MESSAGE -> { - GeolocationMessage( - message = message, - conversationThreadId = conversationThreadId, - state = isBlinkingState - ) - } - - ChatMessage.MessageType.POLL_MESSAGE -> { - PollMessage( - message = message, - conversationThreadId = conversationThreadId, - state = isBlinkingState - ) - } - - ChatMessage.MessageType.DECK_CARD -> { - DeckMessage( - message = message, - conversationThreadId = conversationThreadId, - state = isBlinkingState - ) - } - - else -> { - Log.d("ChatView", "Unknown message type: ${'$'}type") - } - } -} - -private fun ChatMessage.shouldFilter(): Boolean = - systemMessageType in setOf( - ChatMessage.SystemMessageType.REACTION, - ChatMessage.SystemMessageType.REACTION_DELETED, - ChatMessage.SystemMessageType.REACTION_REVOKED, - ChatMessage.SystemMessageType.POLL_VOTED, - ChatMessage.SystemMessageType.MESSAGE_EDITED, - ChatMessage.SystemMessageType.THREAD_CREATED - ) || - (parentMessageId != null && systemMessageType == ChatMessage.SystemMessageType.MESSAGE_DELETED) - fun formatTime(timestampMillis: Long): String { val instant = Instant.ofEpochMilli(timestampMillis) val dateTime = instant.atZone(ZoneId.systemDefault()).toLocalDate() diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt index bf3a6ade3d1..eb135a9b315 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt @@ -71,6 +71,7 @@ fun CommonMessageBody( message: ChatMessage, conversationThreadId: Long? = null, includePadding: Boolean = true, + showAvatar: Boolean = true, playAnimation: Boolean = false, content: @Composable () -> Unit ) { @@ -126,7 +127,7 @@ fun CommonMessageBody( modifier = rowModifier.fillMaxWidth(), horizontalArrangement = if (incoming) Arrangement.Start else Arrangement.End ) { - if (incoming) { + if (incoming && showAvatar) { val errorPlaceholderImage: Int = R.drawable.account_circle_96dp val loadedImage = loadImage(message.avatarUrl, LocalContext.current, errorPlaceholderImage) AsyncImage( 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 index fe2b8eae56a..8bf3b167380 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt @@ -12,11 +12,16 @@ import androidx.compose.runtime.MutableState import com.nextcloud.talk.chat.data.model.ChatMessage @Composable -fun TextMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { +fun TextMessage( + message: ChatMessage, + showAvatar: Boolean, + conversationThreadId: Long? = null, state: MutableState +) { CommonMessageBody( message = message, conversationThreadId = conversationThreadId, playAnimation = state.value, + showAvatar = true, content = { EnrichedText( message From 8dbf59ecc4b60fe17510d63232b55398d3841f02 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 17 Feb 2026 16:18:44 +0100 Subject: [PATCH 11/19] do not show avatars for oneToOne conversation Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/chat/ChatActivity.kt | 2 +- .../nextcloud/talk/chat/viewmodels/ChatViewModel.kt | 11 +++++++++-- .../java/com/nextcloud/talk/ui/chat/TextMessage.kt | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) 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 d38d1522cc8..033f587d9af 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -646,7 +646,7 @@ class ChatActivity : LocalViewThemeUtils provides viewThemeUtils, LocalMessageUtils provides messageUtils ) { - val showAvatar = !isOneToOneConversation() + val showAvatar = uiState.showChatAvatars Log.d(TAG,"showAvatar="+ showAvatar) GetNewChatView( 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 7a9e6535393..e5f95808314 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 @@ -40,6 +40,7 @@ import com.nextcloud.talk.models.domain.ReactionDeletedModel import com.nextcloud.talk.models.json.capabilities.SpreedCapability import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage +import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.opengraph.Reference @@ -337,7 +338,7 @@ class ChatViewModel @AssistedInject constructor( // ------------------------------ data class ChatUiState( val items: List = emptyList(), - val showChatAvatars: Boolean = true, + val showChatAvatars: Boolean = false, // Adding the whole conversation is just an intermediate solution as it is used in the activity. // For the future, only necessary vars from conversation should be in the ui state @@ -429,7 +430,8 @@ class ChatViewModel @AssistedInject constructor( _uiState.update { current -> current.copy( - conversation = conversation + conversation = conversation, + showChatAvatars = !conversation.isOneToOneConversation() ) } } @@ -638,6 +640,11 @@ class ChatViewModel @AssistedInject constructor( observeConversationAndUserEveryTime() } + fun ConversationModel?.isOneToOneConversation(): Boolean { + return this?.type == + ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + } + @Deprecated("use observeConversation") fun getRoom(token: String) { // _getRoomViewState.value = GetRoomStartState 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 index 8bf3b167380..cb36c7b0a61 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt @@ -20,8 +20,8 @@ fun TextMessage( CommonMessageBody( message = message, conversationThreadId = conversationThreadId, + showAvatar = showAvatar, playAnimation = state.value, - showAvatar = true, content = { EnrichedText( message From 8496e503563fa276efe9c35aca90654cfb412236 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 17 Feb 2026 16:49:14 +0100 Subject: [PATCH 12/19] add theming to chat components Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/ui/chat/ChatView.kt | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) 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 index a28045adf76..0974d191428 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -53,6 +54,7 @@ import com.nextcloud.talk.R import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.ui.theme.LocalViewThemeUtils import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged @@ -76,6 +78,9 @@ fun GetNewChatView( advanceLocalLastReadMessageIfNeeded: ((Int) -> Unit?)?, updateRemoteLastReadMessageIfNeeded: (() -> Unit?)? ) { + val viewThemeUtils = LocalViewThemeUtils.current + val colorScheme = viewThemeUtils.getColorScheme(LocalContext.current) + val listState = rememberLazyListState() val showUnreadPopup = remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() @@ -266,11 +271,13 @@ fun GetNewChatView( .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) + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + color = colorScheme.onSecondaryContainer ) } @@ -322,21 +329,29 @@ fun GetNewChatView( @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) + 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" @@ -352,17 +367,21 @@ fun DateHeader(date: LocalDate) { text = text, modifier = Modifier .background( - Color.Gray.copy(alpha = 0.2f), + colorScheme.secondaryContainer, RoundedCornerShape(12.dp) ) .padding(horizontal = 12.dp, vertical = 6.dp), - fontSize = 12.sp + fontSize = 12.sp, + color = colorScheme.onSecondaryContainer ) } } @Composable fun UnreadMessagesMarker(date: LocalDate) { + val viewThemeUtils = LocalViewThemeUtils.current + val colorScheme = viewThemeUtils.getColorScheme(LocalContext.current) + Box( modifier = Modifier .fillMaxWidth(), @@ -372,11 +391,12 @@ fun UnreadMessagesMarker(date: LocalDate) { text = "Unread messages", modifier = Modifier .background( - Color.Gray.copy(alpha = 0.2f), + colorScheme.secondaryContainer, RoundedCornerShape(12.dp) ) .padding(horizontal = 12.dp, vertical = 6.dp), - fontSize = 12.sp + fontSize = 12.sp, + color = colorScheme.onSecondaryContainer ) } } From a800da38bf689f28e45607800430a5f3568a671e Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 18 Feb 2026 17:05:35 +0100 Subject: [PATCH 13/19] avoid unnecessary reverting of message list Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt | 9 ++++----- .../nextcloud/talk/data/database/dao/ChatMessagesDao.kt | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) 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 e5f95808314..730309e13ea 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 @@ -462,18 +462,17 @@ class ChatViewModel @AssistedInject constructor( var lastDate: LocalDate? = null return buildList { - val reversedMessages = messages.asReversed() if (firstUnreadMessageId == null) { firstUnreadMessageId = - reversedMessages.firstOrNull { + messages.firstOrNull { it.jsonMessageId > lastReadMessage }?.jsonMessageId - Log.d(TAG, "reversedMessages.size = ${reversedMessages.size}") + Log.d(TAG, "reversedMessages.size = ${messages.size}") Log.d(TAG, "firstUnreadMessageId = $firstUnreadMessageId") - Log.d(TAG, "conversation.lastReadMessage = ${lastReadMessage}") + Log.d(TAG, "conversation.lastReadMessage = $lastReadMessage") } - for (msg in reversedMessages) { + for (msg in messages) { val date = msg.dateKey() if (date != lastDate) { 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 5d85eb687b5..0cfadf938dc 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 @@ -36,7 +36,7 @@ interface ChatMessagesDao { AND isTemporary = 0 AND (:threadId IS NULL OR threadId = :threadId) AND id > :oldestMessageId - ORDER BY timestamp DESC, id DESC + ORDER BY timestamp ASC, id ASC """ ) fun getMessagesNewerThan( From d3fe0fb093855512275478bfcea6d765758389eb Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 19 Feb 2026 11:47:42 +0100 Subject: [PATCH 14/19] format code Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 2 +- .../talk/chat/MessageInputFragment.kt | 1 - .../talk/chat/data/model/ChatMessage.kt | 2 +- .../talk/chat/viewmodels/ChatViewModel.kt | 20 +++++-------------- .../com/nextcloud/talk/ui/chat/ChatMessage.kt | 13 ------------ .../com/nextcloud/talk/ui/chat/ChatView.kt | 2 +- .../com/nextcloud/talk/ui/chat/TextMessage.kt | 3 ++- 7 files changed, 10 insertions(+), 33 deletions(-) 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 033f587d9af..322a51a0b7c 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -647,7 +647,7 @@ class ChatActivity : LocalMessageUtils provides messageUtils ) { val showAvatar = uiState.showChatAvatars - Log.d(TAG,"showAvatar="+ showAvatar) + Log.d(TAG, "showAvatar=" + showAvatar) GetNewChatView( chatItems = uiState.items, 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 7429039df6f..3164697b637 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -1001,7 +1001,6 @@ class MessageInputFragment : Fragment() { } private fun sendMessage(message: String, sendWithoutNotification: Boolean) { - chatActivity.chatViewModel.onMessageSent() messageInputViewModel.sendChatMessage( 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 6a371aef4bd..9d61494171c 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 @@ -148,7 +148,7 @@ data class ChatMessage( var avatarUrl: String? = null, - var isUnread: Boolean = false, + var isUnread: Boolean = false ) : MessageContentType, MessageContentType.Image { 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 730309e13ea..df03f224962 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 @@ -237,7 +237,6 @@ class ChatViewModel @AssistedInject constructor( val getLastReadMessageFlow = chatRepository.lastReadMessageFlow - sealed interface ViewState object GetReminderStartState : ViewState @@ -329,10 +328,8 @@ class ChatViewModel @AssistedInject constructor( 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. // ------------------------------ @@ -455,10 +452,7 @@ class ChatViewModel @AssistedInject constructor( // ------------------------------ // Build chat items (pure) // ------------------------------ - private fun buildChatItems( - messages: List, - lastReadMessage: Int - ): List { + private fun buildChatItems(messages: List, lastReadMessage: Int): List { var lastDate: LocalDate? = null return buildList { @@ -486,7 +480,6 @@ class ChatViewModel @AssistedInject constructor( add(ChatItem.MessageItem(msg)) } - }.asReversed() } @@ -520,8 +513,8 @@ class ChatViewModel @AssistedInject constructor( withCredentials = credentials, withUrl = url, hasHighPerformanceBackend = - WebSocketConnectionHelper - .getWebSocketInstanceForUser(user) != null + WebSocketConnectionHelper + .getWebSocketInstanceForUser(user) != null ) getCapabilities(user, chatRoomToken, conversation) @@ -543,8 +536,6 @@ class ChatViewModel @AssistedInject constructor( .launchIn(viewModelScope) } - - private fun handleSystemMessages(chatMessageList: List): List { fun shouldRemoveMessage(currentMessage: MutableMap.MutableEntry): Boolean = isInfoMessageAboutDeletion(currentMessage) || @@ -639,10 +630,9 @@ class ChatViewModel @AssistedInject constructor( observeConversationAndUserEveryTime() } - fun ConversationModel?.isOneToOneConversation(): Boolean { - return this?.type == + fun ConversationModel?.isOneToOneConversation(): Boolean = + this?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL - } @Deprecated("use observeConversation") fun getRoom(token: String) { diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessage.kt index 340f26d437b..750c01ab2d6 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessage.kt @@ -8,24 +8,11 @@ package com.nextcloud.talk.ui.chat import android.util.Log -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.runtime.MutableState import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -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.data.model.ChatMessage - @Composable fun ChatMessage( message: ChatMessage, 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 index 0974d191428..8282c2ba3ce 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -342,7 +342,7 @@ fun UnreadMessagesPopup(unreadCount: Int, onClick: () -> Unit, modifier: Modifie Text( text = "$unreadCount new message${if (unreadCount > 1) "s" else ""}", modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - color = colorScheme.onSecondaryContainer, + color = colorScheme.onSecondaryContainer ) } } 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 index cb36c7b0a61..476a77d857e 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt @@ -15,7 +15,8 @@ import com.nextcloud.talk.chat.data.model.ChatMessage fun TextMessage( message: ChatMessage, showAvatar: Boolean, - conversationThreadId: Long? = null, state: MutableState + conversationThreadId: Long? = null, + state: MutableState ) { CommonMessageBody( message = message, From 464a85ef5d66e1f1f57f3241e571afe15ea1456c Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 19 Feb 2026 11:49:17 +0100 Subject: [PATCH 15/19] send features (incl chat-relay) with hello websocket message Signed-off-by: Marcel Hibbe --- .../talk/models/json/websocket/HelloWebSocketMessage.kt | 4 +++- .../nextcloud/talk/webrtc/WebSocketConnectionHelper.java | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) 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/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; } From 757ce61b2b03f7045cdbd3b74ad2448ab7898128 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 19 Feb 2026 12:19:12 +0100 Subject: [PATCH 16/19] handle chat-relay feature response Signed-off-by: Marcel Hibbe --- .../talk/chat/data/ChatMessageRepository.kt | 2 +- .../chat/data/network/OfflineFirstChatRepository.kt | 10 +++++----- .../nextcloud/talk/chat/viewmodels/ChatViewModel.kt | 13 ++++++++----- .../com/nextcloud/talk/webrtc/WebSocketInstance.kt | 11 +++++++++++ 4 files changed, 25 insertions(+), 11 deletions(-) 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 415cc292683..b7f6f19a539 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 @@ -52,7 +52,7 @@ interface ChatMessageRepository : LifecycleAwareManager { fun updateConversation(conversationModel: ConversationModel) - suspend fun loadInitialMessages(withNetworkParams: Bundle, hasHighPerformanceBackend: Boolean) + suspend fun loadInitialMessages(withNetworkParams: Bundle, isChatRelaySupported: Boolean) suspend fun startMessagePolling(hasHighPerformanceBackend: Boolean) 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 589f64d8131..77a286feb53 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 @@ -142,7 +142,7 @@ class OfflineFirstChatRepository @Inject constructor( this.conversationModel = conversationModel } - override suspend fun loadInitialMessages(withNetworkParams: Bundle, hasHighPerformanceBackend: Boolean) { + override suspend fun loadInitialMessages(withNetworkParams: Bundle, isChatRelaySupported: Boolean) { Log.d(TAG, "---- loadInitialMessages ------------") newXChatLastCommonRead = conversationModel.lastCommonReadMessage @@ -157,9 +157,9 @@ class OfflineFirstChatRepository @Inject constructor( val weHaveAtLeastTheLastReadMessage = newestMessageIdFromDb >= conversationModel.lastReadMessage.toLong() Log.d(TAG, "weAlreadyHaveSomeOfflineMessages:$weAlreadyHaveSomeOfflineMessages") Log.d(TAG, "weHaveAtLeastTheLastReadMessage:$weHaveAtLeastTheLastReadMessage") - Log.d(TAG, "hasHighPerformanceBackend:$hasHighPerformanceBackend") + Log.d(TAG, "isChatRelaySupported:$isChatRelaySupported") - if (weAlreadyHaveSomeOfflineMessages && weHaveAtLeastTheLastReadMessage && !hasHighPerformanceBackend) { + if (weAlreadyHaveSomeOfflineMessages && weHaveAtLeastTheLastReadMessage && !isChatRelaySupported) { Log.d( TAG, "Initial online request is skipped because offline messages are up to date" + @@ -171,10 +171,10 @@ class OfflineFirstChatRepository @Inject constructor( // 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 (hasHighPerformanceBackend) { + if (isChatRelaySupported) { Log.d( TAG, - "An online request for newest 100 messages is made because HPB is used (No long " + + "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.)" ) } else if (!weAlreadyHaveSomeOfflineMessages) { 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 df03f224962..cccd9c98a26 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 @@ -512,9 +512,7 @@ class ChatViewModel @AssistedInject constructor( loadInitialMessages( withCredentials = credentials, withUrl = url, - hasHighPerformanceBackend = - WebSocketConnectionHelper - .getWebSocketInstanceForUser(user) != null + isChatRelaySupported = isChatRelaySupported(user) ) getCapabilities(user, chatRoomToken, conversation) @@ -522,6 +520,11 @@ class ChatViewModel @AssistedInject constructor( .launchIn(viewModelScope) } + fun isChatRelaySupported(user: User): Boolean { + val websocketInstance = WebSocketConnectionHelper.getWebSocketInstanceForUser(user) + return websocketInstance?.supportsChatRelay() == true + } + fun observeConversationAndUserEveryTime() { conversationAndUserFlow .onEach { (conversation, user) -> @@ -844,13 +847,13 @@ class ChatViewModel @AssistedInject constructor( } } - suspend fun loadInitialMessages(withCredentials: String, withUrl: String, hasHighPerformanceBackend: Boolean) { + 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.loadInitialMessages( withNetworkParams = bundle, - hasHighPerformanceBackend = hasHighPerformanceBackend + isChatRelaySupported = isChatRelaySupported ) _events.emit(ChatEvent.StartRegularPolling) } 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 9e6a3f2fec6..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 @@ -322,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]) @@ -365,6 +375,7 @@ class WebSocketInstance internal constructor(conversationUser: User, connectionU } fun hasMCU(): Boolean = hasMCU + fun supportsChatRelay(): Boolean = supportsChatRelay @Suppress("Detekt.ComplexMethod") fun joinRoomWithRoomTokenAndSession( From 45f81ae35d52878529f45fad00d61704036e4f00 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 19 Feb 2026 13:33:57 +0100 Subject: [PATCH 17/19] reimplement temporary messages support + message read indicator Signed-off-by: Marcel Hibbe --- .../24.json | 810 ++++++++++++++++++ .../data/database/dao/ChatMessagesDaoTest.kt | 6 +- .../database/migrations/MigrationsTest.kt | 225 ++++- .../network/OfflineFirstChatRepository.kt | 18 +- .../talk/data/database/dao/ChatMessagesDao.kt | 1 - .../data/database/model/ChatMessageEntity.kt | 3 +- .../talk/data/source/local/Migrations.kt | 34 + .../talk/data/source/local/TalkDatabase.kt | 2 +- .../com/nextcloud/talk/ui/chat/Message.kt | 32 +- 9 files changed, 1090 insertions(+), 41 deletions(-) create mode 100644 app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/24.json diff --git a/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/24.json b/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/24.json new file mode 100644 index 00000000000..3a9e74d9997 --- /dev/null +++ b/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/24.json @@ -0,0 +1,810 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "0268ae43ccc84783e56ad472a77b37ba", + "entities": [ + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `username` TEXT, `baseUrl` TEXT, `token` TEXT, `displayName` TEXT, `pushConfigurationState` TEXT, `capabilities` TEXT, `serverVersion` TEXT DEFAULT '', `clientCertificate` TEXT, `externalSignalingServer` TEXT, `current` INTEGER NOT NULL, `scheduledForDeletion` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "pushConfigurationState", + "columnName": "pushConfigurationState", + "affinity": "TEXT" + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT" + }, + { + "fieldPath": "serverVersion", + "columnName": "serverVersion", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "clientCertificate", + "columnName": "clientCertificate", + "affinity": "TEXT" + }, + { + "fieldPath": "externalSignalingServer", + "columnName": "externalSignalingServer", + "affinity": "TEXT" + }, + { + "fieldPath": "current", + "columnName": "current", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledForDeletion", + "columnName": "scheduledForDeletion", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ArbitraryStorage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountIdentifier` INTEGER NOT NULL, `key` TEXT NOT NULL, `object` TEXT, `value` TEXT, PRIMARY KEY(`accountIdentifier`, `key`))", + "fields": [ + { + "fieldPath": "accountIdentifier", + "columnName": "accountIdentifier", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "storageObject", + "columnName": "object", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountIdentifier", + "key" + ] + } + }, + { + "tableName": "Conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `displayName` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `avatarVersion` TEXT NOT NULL, `callFlag` INTEGER NOT NULL, `callRecording` INTEGER NOT NULL, `callStartTime` INTEGER NOT NULL, `canDeleteConversation` INTEGER NOT NULL, `canLeaveConversation` INTEGER NOT NULL, `canStartCall` INTEGER NOT NULL, `description` TEXT NOT NULL, `hasCall` INTEGER NOT NULL, `hasPassword` INTEGER NOT NULL, `isCustomAvatar` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `lastActivity` INTEGER NOT NULL, `lastCommonReadMessage` INTEGER NOT NULL, `lastMessage` TEXT, `lastPing` INTEGER NOT NULL, `lastReadMessage` INTEGER NOT NULL, `lobbyState` TEXT NOT NULL, `lobbyTimer` INTEGER NOT NULL, `messageExpiration` INTEGER NOT NULL, `name` TEXT NOT NULL, `notificationCalls` INTEGER NOT NULL, `notificationLevel` TEXT NOT NULL, `objectType` TEXT NOT NULL, `objectId` TEXT NOT NULL, `participantType` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `readOnly` TEXT NOT NULL, `recordingConsent` INTEGER NOT NULL, `remoteServer` TEXT, `remoteToken` TEXT, `sessionId` TEXT NOT NULL, `status` TEXT, `statusClearAt` INTEGER, `statusIcon` TEXT, `statusMessage` TEXT, `type` TEXT NOT NULL, `unreadMention` INTEGER NOT NULL, `unreadMentionDirect` INTEGER NOT NULL, `unreadMessages` INTEGER NOT NULL, `hasArchived` INTEGER NOT NULL, `hasSensitive` INTEGER NOT NULL, `hasImportant` INTEGER NOT NULL, `hiddenPinnedId` INTEGER, `lastPinnedId` INTEGER, `messageDraft` TEXT, PRIMARY KEY(`internalId`), FOREIGN KEY(`accountId`) REFERENCES `User`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "internalId", + "columnName": "internalId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorId", + "columnName": "actorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorType", + "columnName": "actorType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatarVersion", + "columnName": "avatarVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "callFlag", + "columnName": "callFlag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callRecording", + "columnName": "callRecording", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callStartTime", + "columnName": "callStartTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDeleteConversation", + "columnName": "canDeleteConversation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canLeaveConversation", + "columnName": "canLeaveConversation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canStartCall", + "columnName": "canStartCall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasCall", + "columnName": "hasCall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPassword", + "columnName": "hasPassword", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasCustomAvatar", + "columnName": "isCustomAvatar", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCommonReadMessage", + "columnName": "lastCommonReadMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessage", + "columnName": "lastMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "lastPing", + "columnName": "lastPing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessage", + "columnName": "lastReadMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lobbyState", + "columnName": "lobbyState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lobbyTimer", + "columnName": "lobbyTimer", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageExpiration", + "columnName": "messageExpiration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationCalls", + "columnName": "notificationCalls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLevel", + "columnName": "notificationLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectType", + "columnName": "objectType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantType", + "columnName": "participantType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationReadOnlyState", + "columnName": "readOnly", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordingConsentRequired", + "columnName": "recordingConsent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteServer", + "columnName": "remoteServer", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteToken", + "columnName": "remoteToken", + "affinity": "TEXT" + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "statusClearAt", + "columnName": "statusClearAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "statusIcon", + "columnName": "statusIcon", + "affinity": "TEXT" + }, + { + "fieldPath": "statusMessage", + "columnName": "statusMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unreadMention", + "columnName": "unreadMention", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMentionDirect", + "columnName": "unreadMentionDirect", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMessages", + "columnName": "unreadMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasArchived", + "columnName": "hasArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSensitive", + "columnName": "hasSensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasImportant", + "columnName": "hasImportant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hiddenPinnedId", + "columnName": "hiddenPinnedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastPinnedId", + "columnName": "lastPinnedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "messageDraft", + "columnName": "messageDraft", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "internalId" + ] + }, + "indices": [ + { + "name": "index_Conversations_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Conversations_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `id` INTEGER NOT NULL, `internalConversationId` TEXT NOT NULL, `threadId` INTEGER, `isThread` INTEGER NOT NULL, `actorDisplayName` TEXT NOT NULL, `message` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `isReplyable` INTEGER NOT NULL, `isTemporary` INTEGER NOT NULL, `lastEditActorDisplayName` TEXT, `lastEditActorId` TEXT, `lastEditActorType` TEXT, `lastEditTimestamp` INTEGER, `markdown` INTEGER, `messageParameters` TEXT, `messageType` TEXT NOT NULL, `parent` INTEGER, `reactions` TEXT, `reactionsSelf` TEXT, `referenceId` TEXT, `sendStatus` TEXT, `silent` INTEGER NOT NULL, `systemMessage` TEXT NOT NULL, `threadTitle` TEXT, `threadReplies` INTEGER, `timestamp` INTEGER NOT NULL, `pinnedActorType` TEXT, `pinnedActorId` TEXT, `pinnedActorDisplayName` TEXT, `pinnedAt` INTEGER, `pinnedUntil` INTEGER, `sendAt` INTEGER, PRIMARY KEY(`internalId`), FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "internalId", + "columnName": "internalId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalConversationId", + "columnName": "internalConversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isThread", + "columnName": "isThread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actorDisplayName", + "columnName": "actorDisplayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorId", + "columnName": "actorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorType", + "columnName": "actorType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTimestamp", + "columnName": "expirationTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replyable", + "columnName": "isReplyable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTemporary", + "columnName": "isTemporary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEditActorDisplayName", + "columnName": "lastEditActorDisplayName", + "affinity": "TEXT" + }, + { + "fieldPath": "lastEditActorId", + "columnName": "lastEditActorId", + "affinity": "TEXT" + }, + { + "fieldPath": "lastEditActorType", + "columnName": "lastEditActorType", + "affinity": "TEXT" + }, + { + "fieldPath": "lastEditTimestamp", + "columnName": "lastEditTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "renderMarkdown", + "columnName": "markdown", + "affinity": "INTEGER" + }, + { + "fieldPath": "messageParameters", + "columnName": "messageParameters", + "affinity": "TEXT" + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentMessageId", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactions", + "columnName": "reactions", + "affinity": "TEXT" + }, + { + "fieldPath": "reactionsSelf", + "columnName": "reactionsSelf", + "affinity": "TEXT" + }, + { + "fieldPath": "referenceId", + "columnName": "referenceId", + "affinity": "TEXT" + }, + { + "fieldPath": "sendStatus", + "columnName": "sendStatus", + "affinity": "TEXT" + }, + { + "fieldPath": "silent", + "columnName": "silent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemMessageType", + "columnName": "systemMessage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threadTitle", + "columnName": "threadTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "threadReplies", + "columnName": "threadReplies", + "affinity": "INTEGER" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinnedActorType", + "columnName": "pinnedActorType", + "affinity": "TEXT" + }, + { + "fieldPath": "pinnedActorId", + "columnName": "pinnedActorId", + "affinity": "TEXT" + }, + { + "fieldPath": "pinnedActorDisplayName", + "columnName": "pinnedActorDisplayName", + "affinity": "TEXT" + }, + { + "fieldPath": "pinnedAt", + "columnName": "pinnedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "pinnedUntil", + "columnName": "pinnedUntil", + "affinity": "INTEGER" + }, + { + "fieldPath": "sendAt", + "columnName": "sendAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "internalId" + ] + }, + "indices": [ + { + "name": "index_ChatMessages_internalId", + "unique": true, + "columnNames": [ + "internalId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ChatMessages_internalId` ON `${TABLE_NAME}` (`internalId`)" + }, + { + "name": "index_ChatMessages_internalConversationId", + "unique": false, + "columnNames": [ + "internalConversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessages_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)" + }, + { + "name": "index_ChatMessages_referenceId", + "unique": true, + "columnNames": [ + "referenceId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ChatMessages_referenceId` ON `${TABLE_NAME}` (`referenceId`)" + } + ], + "foreignKeys": [ + { + "table": "Conversations", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "internalConversationId" + ], + "referencedColumns": [ + "internalId" + ] + } + ] + }, + { + "tableName": "ChatBlocks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalConversationId` TEXT NOT NULL, `accountId` INTEGER, `token` TEXT, `threadId` INTEGER, `oldestMessageId` INTEGER NOT NULL, `newestMessageId` INTEGER NOT NULL, `hasHistory` INTEGER NOT NULL, FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalConversationId", + "columnName": "internalConversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "INTEGER" + }, + { + "fieldPath": "oldestMessageId", + "columnName": "oldestMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "newestMessageId", + "columnName": "newestMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasHistory", + "columnName": "hasHistory", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ChatBlocks_internalConversationId", + "unique": false, + "columnNames": [ + "internalConversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatBlocks_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)" + } + ], + "foreignKeys": [ + { + "table": "Conversations", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "internalConversationId" + ], + "referencedColumns": [ + "internalId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0268ae43ccc84783e56ad472a77b37ba')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt b/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt index 6bcce26f8ac..ef2027497c8 100644 --- a/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt +++ b/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt @@ -123,16 +123,16 @@ class ChatMessagesDaoTest { ) ) - chatMessagesDao.getMessagesForConversation(conversation1.internalId).first().forEach { + chatMessagesDao.getMessagesForConversation(conversation1.internalId, null).first().forEach { Log.d(tag, "- next Message for conversation1 (account1)-") Log.d(tag, "id (PK): " + it.id) Log.d(tag, "message: " + it.message) } - val chatMessagesConv1 = chatMessagesDao.getMessagesForConversation(conversation1.internalId) + val chatMessagesConv1 = chatMessagesDao.getMessagesForConversation(conversation1.internalId, null) assertEquals(5, chatMessagesConv1.first().size) - val chatMessagesConv2 = chatMessagesDao.getMessagesForConversation(conversation2.internalId) + val chatMessagesConv2 = chatMessagesDao.getMessagesForConversation(conversation2.internalId, null) assertEquals(1, chatMessagesConv2.first().size) assertEquals("some", chatMessagesConv1.first()[1].message) diff --git a/app/src/androidTest/java/com/nextcloud/talk/data/database/migrations/MigrationsTest.kt b/app/src/androidTest/java/com/nextcloud/talk/data/database/migrations/MigrationsTest.kt index 978bab2e5f4..d8f2f11ca00 100644 --- a/app/src/androidTest/java/com/nextcloud/talk/data/database/migrations/MigrationsTest.kt +++ b/app/src/androidTest/java/com/nextcloud/talk/data/database/migrations/MigrationsTest.kt @@ -7,12 +7,15 @@ package com.nextcloud.talk.data.database.migrations -import androidx.room.Room +import android.database.sqlite.SQLiteConstraintException import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.nextcloud.talk.data.source.local.Migrations import com.nextcloud.talk.data.source.local.TalkDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -20,10 +23,9 @@ import java.io.IOException @RunWith(AndroidJUnit4::class) class MigrationsTest { + companion object { private const val TEST_DB = "migration-test" - private const val INIT_VERSION = 10 // last version before update to offline first - private val TAG = MigrationsTest::class.java.simpleName } @get:Rule @@ -32,21 +34,94 @@ class MigrationsTest { TalkDatabase::class.java ) - @Test - @Throws(IOException::class) - @Suppress("SpreadOperator") - fun migrateAll() { - helper.createDatabase(TEST_DB, INIT_VERSION).apply { - close() - } - - Room.databaseBuilder( - InstrumentationRegistry.getInstrumentation().targetContext, - TalkDatabase::class.java, - TEST_DB - ).addMigrations(*TalkDatabase.MIGRATIONS).build().apply { - openHelper.writableDatabase.close() - } + private fun insertMessage( + db: SupportSQLiteDatabase, + internalId: String, + referenceId: String?, + isTemporary: Int, + timestamp: Long + ) { + db.execSQL(""" + INSERT INTO ChatMessages ( + internalId, + accountId, + token, + id, + internalConversationId, + threadId, + isThread, + actorDisplayName, + message, + actorId, + actorType, + deleted, + expirationTimestamp, + isReplyable, + isTemporary, + lastEditActorDisplayName, + lastEditActorId, + lastEditActorType, + lastEditTimestamp, + markdown, + messageParameters, + messageType, + parent, + reactions, + reactionsSelf, + referenceId, + sendStatus, + silent, + systemMessage, + threadTitle, + threadReplies, + timestamp, + pinnedActorType, + pinnedActorId, + pinnedActorDisplayName, + pinnedAt, + pinnedUntil, + sendAt + ) VALUES ( + '$internalId', + 1, + 'token', + 1, + 'conv', + NULL, + 0, + 'User', + 'Hello', + 'actor1', + 'USER', + 0, + 0, + 0, + $isTemporary, + NULL, + NULL, + NULL, + 0, + 0, + NULL, + 'comment', + NULL, + NULL, + NULL, + ${if (referenceId != null) "'$referenceId'" else "NULL"}, + NULL, + 0, + 0, + NULL, + 0, + $timestamp, + NULL, + NULL, + NULL, + NULL, + NULL, + 0 + ) + """) } @Test @@ -111,4 +186,118 @@ class MigrationsTest { } helper.runMigrationsAndValidate(TEST_DB, 19, true, Migrations.MIGRATION_17_19) } + + @Test + fun migrate23To24_prefersNonTemporary() { + var db = helper.createDatabase(TEST_DB, 23) + + insertMessage(db, "1", "ref1", 1, 1000) + insertMessage(db, "2", "ref1", 0, 2000) + + db.close() + + db = helper.runMigrationsAndValidate( + TEST_DB, + 24, + true, + Migrations.MIGRATION_23_24 + ) + + val cursor = db.query(""" + SELECT internalId, isTemporary, timestamp + FROM ChatMessages + WHERE referenceId = 'ref1' + """) + + assertEquals(1, cursor.count) + assertTrue(cursor.moveToFirst()) + + val internalId = cursor.getString(0) + val isTemporary = cursor.getInt(1) + val timestamp = cursor.getLong(2) + + cursor.close() + + assertEquals("2", internalId) + assertEquals(0, isTemporary) + assertEquals(2000L, timestamp) + } + + @Test + fun migrate23To24_keepsNewestWhenAllTemporary() { + var db = helper.createDatabase(TEST_DB, 23) + + insertMessage(db, "1", "ref2", 1, 1000) + insertMessage(db, "2", "ref2", 1, 2000) + + db.close() + + db = helper.runMigrationsAndValidate( + TEST_DB, + 24, + true, + Migrations.MIGRATION_23_24 + ) + + val cursor = db.query(""" + SELECT internalId, timestamp + FROM ChatMessages + WHERE referenceId = 'ref2' + """) + + assertEquals(1, cursor.count) + assertTrue(cursor.moveToFirst()) + + val internalId = cursor.getString(0) + val timestamp = cursor.getLong(1) + + cursor.close() + + assertEquals("2", internalId) + assertEquals(2000L, timestamp) + } + + @Test + fun migrate23To24_allowsMultipleNullReferenceIds() { + var db = helper.createDatabase(TEST_DB, 23) + + insertMessage(db, "1", null, 0, 1000) + insertMessage(db, "2", null, 0, 2000) + + db.close() + + db = helper.runMigrationsAndValidate( + TEST_DB, + 24, + true, + Migrations.MIGRATION_23_24 + ) + + val cursor = db.query(""" + SELECT COUNT(*) FROM ChatMessages WHERE referenceId IS NULL + """) + + assertTrue(cursor.moveToFirst()) + val count = cursor.getInt(0) + + cursor.close() + + assertEquals(2, count) + } + + @Test(expected = SQLiteConstraintException::class) + fun migrate23To24_enforcesUniqueIndex() { + var db = helper.createDatabase(TEST_DB, 23) + db.close() + + db = helper.runMigrationsAndValidate( + TEST_DB, + 24, + true, + Migrations.MIGRATION_23_24 + ) + + insertMessage(db, "1", "dup", 0, 1000) + insertMessage(db, "2", "dup", 0, 2000) + } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt index 77a286feb53..9d33d8c35aa 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 @@ -586,6 +586,9 @@ class OfflineFirstChatRepository @Inject constructor( when (result) { is ChatPullResult.Success -> { + newXChatLastCommonRead = result.lastCommonRead // expose newXChatLastCommonRead to viewmodel for + // calculation + val hasHistory = getHasHistory(HTTP_CODE_OK, lookIntoFuture) Log.d( @@ -983,12 +986,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)) @@ -1110,6 +1113,9 @@ class OfflineFirstChatRepository @Inject constructor( val chatMessageEntities = chatMessages.map { it.asEntity(currentUser.id!!) } + + // This may overwrite message with the same referenceId, which is expected (temp messages will be overwritten + // by received ones) chatDao.upsertChatMessages(chatMessageEntities) return chatMessageEntities 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 0cfadf938dc..ba6867c0ed5 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 @@ -33,7 +33,6 @@ interface ChatMessagesDao { SELECT * FROM ChatMessages WHERE internalConversationId = :internalConversationId - AND isTemporary = 0 AND (:threadId IS NULL OR threadId = :threadId) AND id > :oldestMessageId ORDER BY timestamp ASC, id ASC diff --git a/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt b/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt index 53ccb27a431..1c9ee4f7480 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt @@ -27,7 +27,8 @@ import com.nextcloud.talk.chat.data.model.ChatMessage ], indices = [ Index(value = ["internalId"], unique = true), - Index(value = ["internalConversationId"]) + Index(value = ["internalConversationId"]), + Index(value = ["referenceId"], unique = true) ] ) data class ChatMessageEntity( diff --git a/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt b/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt index dc5e8ace347..32de4edb035 100644 --- a/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt +++ b/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt @@ -100,6 +100,13 @@ object Migrations { } } + val MIGRATION_23_24 = object : Migration(23, 24) { + override fun migrate(db: SupportSQLiteDatabase) { + Log.i("Migrations", "Migrating 23 to 24") + migrateToUniqueReferenceIds(db) + } + } + //endregion fun migrateToRoom(db: SupportSQLiteDatabase) { @@ -418,4 +425,31 @@ object Migrations { Log.i("Migrations", "Something went wrong when adding column silent to table ChatMessages", e) } } + + fun migrateToUniqueReferenceIds(db: SupportSQLiteDatabase) { + // referenceId could exist multiple times (they should not, but just in case..). Before migrating to unique + // index, make sure to delete all duplicates. + db.execSQL(""" + DELETE FROM ChatMessages + WHERE rowid NOT IN ( + -- Keep the latest non-temporary per referenceId + SELECT MAX(rowid) + FROM ChatMessages + WHERE referenceId IS NOT NULL + GROUP BY referenceId + UNION + -- Keep all messages without referenceId + SELECT rowid + FROM ChatMessages + WHERE referenceId IS NULL + ) + """) + + // Now it's safe to create the unique index + db.execSQL(""" + CREATE UNIQUE INDEX IF NOT EXISTS + index_ChatMessages_referenceId + ON ChatMessages(referenceId) + """) + } } diff --git a/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt b/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt index 59ae11ccbca..70f71b86e92 100644 --- a/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt +++ b/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt @@ -48,7 +48,7 @@ import java.util.Locale ChatMessageEntity::class, ChatBlockEntity::class ], - version = 23, + version = 24, autoMigrations = [ AutoMigration(from = 9, to = 10), AutoMigration(from = 16, to = 17, spec = AutoMigration16To17::class), diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt index eb135a9b315..5a204f8a400 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt @@ -50,6 +50,7 @@ import coil.compose.AsyncImage import com.nextcloud.talk.R import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.contacts.loadImage +import com.nextcloud.talk.data.database.model.SendStatus import com.nextcloud.talk.models.json.chat.ReadStatus import com.nextcloud.talk.ui.theme.LocalMessageUtils import com.nextcloud.talk.ui.theme.LocalViewThemeUtils @@ -274,17 +275,26 @@ fun TimeDisplay(message: ChatMessage) { @Composable 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 = colorScheme.onSurfaceVariant - ) - } + val icon = if (message.sendStatus == SendStatus.FAILED) { + painterResource(R.drawable.baseline_error_outline_24) + } else if (message.isTemporary) { + painterResource(R.drawable.baseline_schedule_24) + } else if (message.readStatus == ReadStatus.READ) { + painterResource(R.drawable.ic_check_all) + } else if (message.readStatus == ReadStatus.SENT) { + painterResource(R.drawable.ic_check) + } else { + painterResource(R.drawable.ic_check) + } // why is readStatus NONE ? because readStatus must be set by newXChatLastCommonRead. do this in viewmodel. + + Icon( + icon, + "", + modifier = Modifier + .padding(start = 4.dp) + .size(16.dp), + tint = colorScheme.onSurfaceVariant + ) } @Composable From fda9c179d7a96fc4906a217c4069f5f83b75d38f Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 20 Feb 2026 00:15:28 +0100 Subject: [PATCH 18/19] WIP move logic away from messages UI, introduce immutable ChatMessageUi, reimplement status icon handling etc Signed-off-by: Marcel Hibbe --- .../talk/adapters/items/ConversationItem.kt | 4 +- .../talk/chat/data/model/ChatMessage.kt | 11 + .../network/OfflineFirstChatRepository.kt | 45 ++--- .../talk/chat/ui/model/ChatMessageUi.kt | 89 ++++++++ .../talk/chat/viewmodels/ChatViewModel.kt | 91 ++++++--- .../talk/contextchat/ContextChatView.kt | 4 +- .../OfflineFirstConversationsRepository.kt | 10 +- .../database/mappers/ChatMessageMapUtils.kt | 6 +- .../database/mappers/ConversationMapUtils.kt | 2 +- .../ThreadsOverviewActivity.kt | 4 +- .../nextcloud/talk/ui/ComposeChatAdapter.kt | 190 ------------------ .../{Message.kt => ChatMessageScaffold.kt} | 97 +++++---- .../{ChatMessage.kt => ChatMessageView.kt} | 35 ++-- .../nextcloud/talk/ui/chat/ChatUiMessage.kt | 14 -- .../com/nextcloud/talk/ui/chat/ChatView.kt | 87 +------- .../com/nextcloud/talk/ui/chat/DeckMessage.kt | 72 +++---- .../talk/ui/chat/GeolocationMessage.kt | 52 ++--- .../nextcloud/talk/ui/chat/ImageMessage.kt | 92 ++++----- .../com/nextcloud/talk/ui/chat/LinkMessage.kt | 4 +- .../com/nextcloud/talk/ui/chat/PollMessage.kt | 56 +++--- .../nextcloud/talk/ui/chat/SystemMessage.kt | 65 +++--- .../com/nextcloud/talk/ui/chat/TextMessage.kt | 10 +- .../nextcloud/talk/ui/chat/VoiceMessage.kt | 8 +- .../talk/utils/message/MessageUtils.kt | 15 ++ .../talk/json/ConversationConversionTest.kt | 4 +- 25 files changed, 461 insertions(+), 606 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt rename app/src/main/java/com/nextcloud/talk/ui/chat/{Message.kt => ChatMessageScaffold.kt} (81%) rename app/src/main/java/com/nextcloud/talk/ui/chat/{ChatMessage.kt => ChatMessageView.kt} (75%) delete mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/ChatUiMessage.kt diff --git a/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt b/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt index a21a8d7b2e8..ab5ed2e23a5 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt @@ -28,7 +28,7 @@ import com.nextcloud.talk.adapters.items.ConversationItem.ConversationItemViewHo import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.ChatMessage.MessageType -import com.nextcloud.talk.data.database.mappers.asModel +import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.RvItemConversationWithLastMessageBinding import com.nextcloud.talk.extensions.loadConversationAvatar @@ -60,7 +60,7 @@ class ConversationItem( ISectionable, IFilterable { private var header: GenericTextHeaderItem? = null - private val chatMessage = model.lastMessage?.asModel() + private val chatMessage = model.lastMessage?.toDomainModel() var mHolder: ConversationItemViewHolder? = null constructor( diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt index 9d61494171c..d3130e3e69f 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 @@ -14,6 +14,7 @@ import android.util.Log import com.bluelinelabs.logansquare.annotation.JsonIgnore import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication +import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.data.database.model.SendStatus import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.chat.ChatUtils.Companion.getParsedMessage @@ -24,8 +25,12 @@ import com.nextcloud.talk.utils.CapabilitiesUtil import com.stfalcon.chatkit.commons.models.IUser import com.stfalcon.chatkit.commons.models.MessageContentType import java.security.MessageDigest +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId import java.util.Date +// Domain model for chat message. No entries here that are only necessary for the database layer, nor only for UI layer data class ChatMessage( var isGrouped: Boolean = false, @@ -74,6 +79,7 @@ data class ChatMessage( var parentMessageId: Long? = null, + @Deprecated("delete with chatkit") var readStatus: Enum = ReadStatus.NONE, var messageType: String? = null, @@ -372,6 +378,11 @@ data class ChatMessage( val isDeletedCommentMessage: Boolean get() = "comment_deleted" == messageType + fun ChatMessage.dateKey(): LocalDate = + Instant.ofEpochMilli(timestamp * 1000L) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + enum class MessageType { REGULAR_TEXT_MESSAGE, SYSTEM_MESSAGE, diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt index 9d33d8c35aa..6f16046351d 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 @@ -16,7 +16,7 @@ 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 @@ -88,8 +88,7 @@ class OfflineFirstChatRepository @Inject constructor( private val _updateMessageFlow: MutableSharedFlow = MutableSharedFlow() - override val lastCommonReadFlow: - Flow + override val lastCommonReadFlow: Flow get() = _lastCommonReadFlow private val _lastCommonReadFlow: @@ -276,13 +275,11 @@ class OfflineFirstChatRepository @Inject constructor( } } - // private fun updateUiForLastCommonRead() { - // scope.launch { - // newXChatLastCommonRead?.let { - // _lastCommonReadFlow.emit(it) - // } - // } - // } + private suspend fun updateUiForLastCommonRead() { + newXChatLastCommonRead?.let { + _lastCommonReadFlow.emit(it) + } + } suspend fun initLongPolling() { Log.d(TAG, "---- initLongPolling ------------") @@ -523,7 +520,7 @@ class OfflineFirstChatRepository @Inject constructor( return chatDao .getChatMessageForConversationNullable(internalConversationId, messageId) - .mapNotNull { it?.asModel() } + .mapNotNull { it?.toDomainModel() } .take(1) .timeout(5_000.microseconds) .catch { /* timeout -> emit nothing */ } @@ -533,7 +530,7 @@ class OfflineFirstChatRepository @Inject constructor( chatDao.getChatMessageForConversation( internalConversationId, messageId - ).map(ChatMessageEntity::asModel) + ).map(ChatMessageEntity::toDomainModel) fun pullMessagesFlow(bundle: Bundle): Flow = flow { @@ -586,8 +583,8 @@ class OfflineFirstChatRepository @Inject constructor( when (result) { is ChatPullResult.Success -> { - newXChatLastCommonRead = result.lastCommonRead // expose newXChatLastCommonRead to viewmodel for - // calculation + newXChatLastCommonRead = result.lastCommonRead + updateUiForLastCommonRead() val hasHistory = getHasHistory(HTTP_CODE_OK, lookIntoFuture) @@ -812,7 +809,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) { @@ -827,7 +824,7 @@ class OfflineFirstChatRepository @Inject constructor( messageLimit, threadId ).map { - it.map(ChatMessageEntity::asModel) + it.map(ChatMessageEntity::toDomainModel) }.first() val list = getMessagesBefore( @@ -883,7 +880,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 @@ -920,7 +917,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)) @@ -946,7 +943,7 @@ class OfflineFirstChatRepository @Inject constructor( messageToResend.sendStatus = SendStatus.PENDING chatDao.updateChatMessage(messageToResend) - val messageToResendModel = messageToResend.asModel() + val messageToResendModel = messageToResend.toDomainModel() _updateMessageFlow.emit(messageToResendModel) sendChatMessage( @@ -1028,7 +1025,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) { @@ -1066,7 +1063,7 @@ class OfflineFirstChatRepository @Inject constructor( 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") } @@ -1076,7 +1073,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") } @@ -1185,7 +1182,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() ) @@ -1208,7 +1205,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() ) 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..206fd36f1a0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt @@ -0,0 +1,89 @@ +/* + * 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 androidx.compose.runtime.Immutable +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.database.model.SendStatus +import java.time.LocalDate + +// immutable class for chat message UI. only val, no vars! +@Immutable +data class ChatMessageUi( + val id: Int, + val type: ChatMessage.MessageType, + val text: String, + val message: String, // what is the difference between message and text? remove one? + val renderMarkdown: Boolean, + val isLinkPreview: Boolean, + val actorDisplayName: String, + val isThread: Boolean, + val threadTitle: String, + val incoming: Boolean, + val isDeleted: Boolean, + val avatarUrl: String?, + val imageUrl: String?, + val statusIcon: MessageStatusIcon, + val timestamp: Long, + val date: LocalDate +) + +enum class MessageStatusIcon { + FAILED, + SENDING, + READ, + SENT, +} + +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 +} + +// Domain model (ChatMessage) to UI model (ChatMessageUi) +fun ChatMessage.toUiModel( + lastCommonReadMessageId: Int +): ChatMessageUi { + + return ChatMessageUi( + id = jsonMessageId, + type = getCalculateMessageType(), + text = text, + message = message.orEmpty(), // what is the difference between message and text? remove one? + renderMarkdown = renderMarkdown == true, + isLinkPreview = isLinkPreview(), + actorDisplayName = actorDisplayName.orEmpty(), + threadTitle = threadTitle.orEmpty(), + isThread = isThread, + incoming = incoming, + isDeleted = isDeleted, + avatarUrl = avatarUrl, + imageUrl = imageUrl, + statusIcon = resolveStatusIcon( + jsonMessageId, + lastCommonReadMessageId, + isTemporary, + sendStatus + ), + timestamp = timestamp, + date = dateKey() + ) +} 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 cccd9c98a26..b20acdc5b63 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,10 +25,12 @@ import com.nextcloud.talk.chat.data.io.MediaPlayerManager import com.nextcloud.talk.chat.data.io.MediaRecorderManager import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.toUiModel import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository import com.nextcloud.talk.conversationlist.data.network.OfflineFirstConversationsRepository import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel.Companion.FOLLOWED_THREADS_EXIST -import com.nextcloud.talk.data.database.mappers.asModel +import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.toIntOrZero @@ -89,9 +91,7 @@ import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.io.File -import java.time.Instant import java.time.LocalDate -import java.time.ZoneId import javax.inject.Inject @Suppress("TooManyFunctions", "LongParameterList") @@ -381,13 +381,16 @@ class ChatViewModel @AssistedInject constructor( // ------------------------------ // Messages // ------------------------------ - private fun Flow>.mapToChatMessages(userId: String): Flow> = + private fun Flow>.mapToChatMessages( + userId: String + ): Flow> = map { entities -> - entities.map(ChatMessageEntity::asModel) - .onEach { msg -> - msg.avatarUrl = getAvatarUrl(msg) - msg.incoming = msg.actorId != userId + entities.map { entity -> + entity.toDomainModel().apply { + avatarUrl = getAvatarUrl(this) + incoming = actorId != userId } + } } private val messagesFlow: Flow> = @@ -435,11 +438,15 @@ class ChatViewModel @AssistedInject constructor( .launchIn(viewModelScope) } + // val lastCommonReadMessageId = getLastCommonReadFlow.first() + // ------------------------------ // Observe messages // ------------------------------ private fun observeMessages() { - messagesFlow + combine(messagesFlow, getLastCommonReadFlow) { messages, lastRead -> + messages.map { it.toUiModel(lastRead) } + } .onEach { messages -> val items = buildChatItems(messages, lastReadMessage) _uiState.update { current -> @@ -447,38 +454,57 @@ class ChatViewModel @AssistedInject constructor( } } .launchIn(viewModelScope) + + // messagesFlow + // .map { messages -> + // messages.map { + // it.toUiModel( + // getLastCommonReadFlow.first() + // ) + // } + // } + // .onEach { messages -> + // val items = buildChatItems(messages, lastReadMessage) + // _uiState.update { current -> + // current.copy(items = items) + // } + // } + // .launchIn(viewModelScope) } // ------------------------------ // Build chat items (pure) // ------------------------------ - private fun buildChatItems(messages: List, lastReadMessage: Int): List { + private fun buildChatItems( + uiMessages: List, + lastReadMessage: Int + ): List { var lastDate: LocalDate? = null return buildList { if (firstUnreadMessageId == null) { firstUnreadMessageId = - messages.firstOrNull { - it.jsonMessageId > lastReadMessage - }?.jsonMessageId - Log.d(TAG, "reversedMessages.size = ${messages.size}") + 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 (msg in messages) { - val date = msg.dateKey() + for (uiMessage in uiMessages) { + val date = uiMessage.date if (date != lastDate) { add(ChatItem.DateHeaderItem(date)) lastDate = date } - if (!oneOrMoreMessagesWereSent && msg.jsonMessageId == firstUnreadMessageId) { + if (!oneOrMoreMessagesWereSent && uiMessage.id == firstUnreadMessageId) { add(ChatItem.UnreadMessagesMarkerItem(date)) } - add(ChatItem.MessageItem(msg)) + add(ChatItem.MessageItem(uiMessage)) } }.asReversed() } @@ -602,10 +628,9 @@ class ChatViewModel @AssistedInject constructor( return chatMessageMap.values.toList() } - fun ChatMessage.dateKey(): LocalDate = - Instant.ofEpochMilli(timestamp * 1000L) - .atZone(ZoneId.systemDefault()) - .toLocalDate() + + + // val timeString = DateUtils.getLocalTimeStringFromTimestamp(message.timestamp) fun getAvatarUrl(message: ChatMessage): String = if (this::currentUser.isInitialized) { @@ -867,10 +892,7 @@ class ChatViewModel @AssistedInject constructor( val messageId = currentItems .asReversed() - .firstNotNullOfOrNull { item -> - (item as? ChatItem.MessageItem) - ?.message - ?.jsonMessageId + .firstNotNullOfOrNull { item -> (item as? ChatItem.MessageItem)?.uiMessage?.id } Log.d(TAG, "Compose load more, messageId: $messageId") @@ -1268,7 +1290,7 @@ class ChatViewModel @AssistedInject constructor( if (messages.isNotEmpty()) { val message = messages[0] - emit(message.asModel()) + emit(message.toDomainModel()) } else { emit(null) } @@ -1510,18 +1532,27 @@ class ChatViewModel @AssistedInject constructor( data class Error(val throwable: Throwable) : ChatEvent() } + + + + sealed interface ChatItem { - fun messageOrNull(): ChatMessage? = (this as? MessageItem)?.message + fun messageOrNull(): ChatMessageUi? = (this as? MessageItem)?.uiMessage fun dateOrNull(): LocalDate? = (this as? DateHeaderItem)?.date fun stableKey(): Any = when (this) { - is MessageItem -> "msg_${message.id}" + is MessageItem -> "msg_${uiMessage.id}" is DateHeaderItem -> "header_$date" is UnreadMessagesMarkerItem -> "last_read_$date" } - data class MessageItem(val message: ChatMessage) : ChatItem + // 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 } 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 a81d996262d..4985da0f33f 100644 --- a/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/contextchat/ContextChatView.kt @@ -48,7 +48,7 @@ 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.theme.ViewThemeUtils @@ -177,7 +177,7 @@ 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 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 817a526373a..a98cd7bbf7e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt @@ -13,7 +13,7 @@ import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository import com.nextcloud.talk.data.database.dao.ConversationsDao import com.nextcloud.talk.data.database.mappers.asEntity -import com.nextcloud.talk.data.database.mappers.asModel +import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.data.database.model.ConversationEntity import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User @@ -65,7 +65,7 @@ class OfflineFirstConversationsRepository @Inject constructor( if (entity == null) { ConversationResult.NotFound } else { - ConversationResult.Found(entity.asModel()) + ConversationResult.Found(entity.toDomainModel()) } } @@ -77,7 +77,7 @@ class OfflineFirstConversationsRepository @Inject constructor( if (networkMonitor.isOnline.value) { val conversationEntitiesFromSync = getRoomsFromServer(user) if (!conversationEntitiesFromSync.isNullOrEmpty()) { - val conversationModelsFromSync = conversationEntitiesFromSync.map(ConversationEntity::asModel) + val conversationModelsFromSync = conversationEntitiesFromSync.map(ConversationEntity::toDomainModel) _roomListFlow.emit(conversationModelsFromSync) } } @@ -175,12 +175,12 @@ class OfflineFirstConversationsRepository @Inject constructor( private suspend fun getListOfConversations(accountId: Long): List = dao.getConversationsForUser(accountId).map { - it.map(ConversationEntity::asModel) + it.map(ConversationEntity::toDomainModel) }.first() private suspend fun getConversation(accountId: Long, token: String): ConversationModel? { val entity = dao.getConversationForUser(accountId, token).first() - return entity?.asModel() + return entity?.toDomainModel() } companion object { diff --git a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt index e7ef0df94fa..e83832eca66 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 @@ -53,7 +53,7 @@ fun ChatMessageJson.asEntity(accountId: Long) = sendAt = sendAt ) -fun ChatMessageEntity.asModel() = +fun ChatMessageEntity.toDomainModel() = ChatMessage( jsonMessageId = id.toInt(), message = message, @@ -81,7 +81,7 @@ fun ChatMessageEntity.asModel() = referenceId = referenceId, isTemporary = isTemporary, sendStatus = sendStatus, - readStatus = ReadStatus.NONE, + // readStatus = ReadStatus.NONE, silent = silent, threadTitle = threadTitle, threadReplies = threadReplies, @@ -93,7 +93,7 @@ fun ChatMessageEntity.asModel() = sendAt = sendAt ) -fun ChatMessageJson.asModel() = +fun ChatMessageJson.toDomainModel() = ChatMessage( jsonMessageId = id.toInt(), message = message, diff --git a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt index 7dad703069c..45946b7c8c7 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt @@ -69,7 +69,7 @@ fun ConversationModel.asEntity() = lastPinnedId = lastPinnedId ) -fun ConversationEntity.asModel() = +fun ConversationEntity.toDomainModel() = ConversationModel( internalId = internalId, accountId = accountId, diff --git a/app/src/main/java/com/nextcloud/talk/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 e3ce4c1c901..00000000000 --- a/app/src/main/java/com/nextcloud/talk/ui/ComposeChatAdapter.kt +++ /dev/null @@ -1,190 +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 androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.nextcloud.talk.chat.data.model.ChatMessage -import com.nextcloud.talk.ui.chat.GetView - -// @Suppress("FunctionNaming", "TooManyFunctions", "LongMethod", "StaticFieldLeak", "LargeClass") -// class ComposeChatAdapter( -// var messages: List? = null, -// var messageId: String? = null, -// 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 -// } -// -// 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) -// } -// } - -@Preview(showBackground = true, widthDp = 380, heightDp = 800) -@Composable -@Suppress("MagicNumber", "LongMethod") -fun AllMessageTypesPreview() { - val sampleMessages = remember { - listOf( - // Text Messages - ChatMessage().apply { - jsonMessageId = 1 - actorId = "user1" - message = "I love Nextcloud" - timestamp = System.currentTimeMillis() - actorDisplayName = "User1" - messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name - }, - ChatMessage().apply { - jsonMessageId = 2 - actorId = "user1_id" - message = "I love Nextcloud" - timestamp = System.currentTimeMillis() - actorDisplayName = "User2" - messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name - }, - ChatMessage().apply { - jsonMessageId = 3 - actorId = "user1_id" - message = "This is a really really really really really really really really really long message" - timestamp = System.currentTimeMillis() - actorDisplayName = "User2" - messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name - }, - ChatMessage().apply { - jsonMessageId = 4 - actorId = "user1_id" - message = "some \n linebreak" - timestamp = System.currentTimeMillis() - actorDisplayName = "User2" - messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name - }, - ChatMessage().apply { - jsonMessageId = 5 - actorId = "user1_id" - threadTitle = "Thread title" - isThread = true - message = "Content of a first thread message" - timestamp = System.currentTimeMillis() - actorDisplayName = "User2" - messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name - }, - ChatMessage().apply { - jsonMessageId = 6 - actorId = "user1_id" - threadTitle = "looooooooooooong Thread title" - isThread = true - message = "Content" - timestamp = System.currentTimeMillis() - actorDisplayName = "User2" - messageType = ChatMessage.MessageType.REGULAR_TEXT_MESSAGE.name - } - ) - } - - MaterialTheme { - Box(modifier = Modifier.fillMaxSize()) { - GetView( - messages = sampleMessages, - messageIdToBlink = "", - user = null - ) - } - } -} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt similarity index 81% rename from app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt rename to app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt index 5a204f8a400..1b4e808e647 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/Message.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt @@ -48,10 +48,9 @@ import androidx.core.graphics.ColorUtils import androidx.emoji2.widget.EmojiTextView import coil.compose.AsyncImage 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.chat.ui.model.MessageStatusIcon import com.nextcloud.talk.contacts.loadImage -import com.nextcloud.talk.data.database.model.SendStatus -import com.nextcloud.talk.models.json.chat.ReadStatus import com.nextcloud.talk.ui.theme.LocalMessageUtils import com.nextcloud.talk.ui.theme.LocalViewThemeUtils import com.nextcloud.talk.utils.DateUtils @@ -68,27 +67,27 @@ private const val MESSAGE_LENGTH_THRESHOLD = 25 private const val ANIMATED_BLINK = 500 @Composable -fun CommonMessageBody( - message: ChatMessage, +fun MessageScaffold( + uiMessage: ChatMessageUi, conversationThreadId: Long? = null, includePadding: Boolean = true, showAvatar: 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 + fun shouldShowTimeNextToContent(message: ChatMessageUi): Boolean { + val containsLinebreak = message.message.contains("\n") ?: false || + message.message.contains("\r") ?: false - return ((message.message?.length ?: 0) < MESSAGE_LENGTH_THRESHOLD) && + return ((message.message.length ?: 0) < MESSAGE_LENGTH_THRESHOLD) && !isFirstMessageOfThreadInNormalChat(message, conversationThreadId) && - message.messageParameters.isNullOrEmpty() && + // message.messageParameters.isNullOrEmpty() && !containsLinebreak } - val incoming = message.incoming + val incoming = uiMessage.incoming val color = if (incoming) { - if (message.isDeleted) { + if (uiMessage.isDeleted) { getColorFromTheme(LocalContext.current, R.color.bg_message_list_incoming_bubble_deleted) } else { getColorFromTheme(LocalContext.current, R.color.bg_message_list_incoming_bubble) @@ -97,9 +96,9 @@ fun CommonMessageBody( val viewThemeUtils = LocalViewThemeUtils.current val outgoingBubbleColor = viewThemeUtils.talk - .getOutgoingMessageBubbleColor(LocalContext.current, message.isDeleted, false) + .getOutgoingMessageBubbleColor(LocalContext.current, uiMessage.isDeleted, false) - if (message.isDeleted) { + if (uiMessage.isDeleted) { ColorUtils.setAlphaComponent(outgoingBubbleColor, HALF_OPACITY) } else { outgoingBubbleColor @@ -130,7 +129,7 @@ fun CommonMessageBody( ) { if (incoming && showAvatar) { val errorPlaceholderImage: Int = R.drawable.account_circle_96dp - val loadedImage = loadImage(message.avatarUrl, LocalContext.current, errorPlaceholderImage) + val loadedImage = loadImage(uiMessage.avatarUrl, LocalContext.current, errorPlaceholderImage) AsyncImage( model = loadedImage, contentDescription = stringResource(R.string.user_avatar), @@ -171,15 +170,15 @@ fun CommonMessageBody( if (incoming) { Text( - message.actorDisplayName.toString(), + uiMessage.actorDisplayName, fontSize = AUTHOR_TEXT_SIZE, color = colorScheme.onSurfaceVariant ) } - ThreadTitle(message) + ThreadTitle(uiMessage) - if (shouldShowTimeNextToContent(message)) { + if (shouldShowTimeNextToContent(uiMessage)) { Row( verticalAlignment = Alignment.CenterVertically ) { @@ -188,8 +187,8 @@ fun CommonMessageBody( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 6.dp, start = 8.dp) ) { - TimeDisplay(message) - ReadStatus(message) + TimeDisplay(uiMessage) + ReadStatus(uiMessage) } } } else { @@ -198,8 +197,8 @@ fun CommonMessageBody( modifier = Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically ) { - TimeDisplay(message) - ReadStatus(message) + TimeDisplay(uiMessage) + ReadStatus(uiMessage) } } } @@ -208,7 +207,7 @@ fun CommonMessageBody( } @Composable -fun CommonMessageQuote(context: Context, message: ChatMessage, incoming: Boolean) { +fun CommonMessageQuote(context: Context, message: ChatMessageUi, incoming: Boolean) { val color = colorResource(R.color.high_emphasis_text) Row( modifier = Modifier @@ -262,7 +261,7 @@ private fun getColorFromTheme(context: Context, resourceId: Int): Int { } @Composable -fun TimeDisplay(message: ChatMessage) { +fun TimeDisplay(message: ChatMessageUi) { val timeString = DateUtils(LocalContext.current) .getLocalTimeStringFromTimestamp(message.timestamp) Text( @@ -274,22 +273,17 @@ fun TimeDisplay(message: ChatMessage) { } @Composable -fun ReadStatus(message: ChatMessage) { - val icon = if (message.sendStatus == SendStatus.FAILED) { - painterResource(R.drawable.baseline_error_outline_24) - } else if (message.isTemporary) { - painterResource(R.drawable.baseline_schedule_24) - } else if (message.readStatus == ReadStatus.READ) { - painterResource(R.drawable.ic_check_all) - } else if (message.readStatus == ReadStatus.SENT) { - painterResource(R.drawable.ic_check) - } else { - painterResource(R.drawable.ic_check) - } // why is readStatus NONE ? because readStatus must be set by newXChatLastCommonRead. do this in viewmodel. +fun ReadStatus(message: ChatMessageUi) { + val icon = when (message.statusIcon) { + MessageStatusIcon.FAILED -> painterResource(R.drawable.baseline_error_outline_24) + MessageStatusIcon.SENDING -> painterResource(R.drawable.baseline_schedule_24) + MessageStatusIcon.READ -> painterResource(R.drawable.ic_check_all) + MessageStatusIcon.SENT -> painterResource(R.drawable.ic_check) + } Icon( - icon, - "", + painter = icon, + contentDescription = "", modifier = Modifier .padding(start = 4.dp) .size(16.dp), @@ -298,7 +292,7 @@ fun ReadStatus(message: ChatMessage) { } @Composable -fun ThreadTitle(message: ChatMessage) { +fun ThreadTitle(message: ChatMessageUi) { if (isFirstMessageOfThreadInNormalChat(message)) { Row { val read = painterResource(R.drawable.outline_forum_24) @@ -320,25 +314,28 @@ fun ThreadTitle(message: ChatMessage) { } @Composable -fun EnrichedText(message: ChatMessage) { +fun EnrichedText(message: ChatMessageUi) { val viewThemeUtils = LocalViewThemeUtils.current val messageUtils = LocalMessageUtils.current AndroidView(factory = { ctx -> - var processedMessageText = messageUtils.enrichChatMessageText( + var processedMessageText = messageUtils.enrichChatMessageUiText( context = ctx, message = message, incoming = message.incoming, viewThemeUtils = viewThemeUtils ) - processedMessageText = messageUtils.processMessageParameters( - themingContext = ctx, - viewThemeUtils = viewThemeUtils, - spannedText = processedMessageText!!, - message = message, - itemView = null - ) + // Here it gets difficult! we need to change the handling of messageParameters! + // for now, processMessageParameters is commented out and processedMessageText is not further processed + + // processedMessageText = messageUtils.processMessageParameters( + // themingContext = ctx, + // viewThemeUtils = viewThemeUtils, + // spannedText = processedMessageText!!, + // message = message, + // itemView = null + // ) EmojiTextView(ctx).apply { layoutParams = @@ -350,14 +347,14 @@ fun EnrichedText(message: ChatMessage) { textAlignment = View.TEXT_ALIGNMENT_VIEW_START // text = processedMessageText // added messageId just for debugging - text = "" + processedMessageText + " (" + message.jsonMessageId + ")" + text = "" + processedMessageText + " (" + message.id + ")" setPadding(0, INT_8, 0, 0) } }, modifier = Modifier) } -fun isFirstMessageOfThreadInNormalChat(message: ChatMessage, conversationThreadId: Long? = null): Boolean = +fun isFirstMessageOfThreadInNormalChat(message: ChatMessageUi, conversationThreadId: Long? = null): Boolean = conversationThreadId == null && message.isThread @Composable diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt similarity index 75% rename from app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessage.kt rename to app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt index 750c01ab2d6..a4231a1841a 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt @@ -12,17 +12,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.chat.ui.model.ChatMessageUi @Composable -fun ChatMessage( - message: ChatMessage, +fun ChatMessageView( + message: ChatMessageUi, showAvatar: Boolean, conversationThreadId: Long? = null, isBlinkingState: MutableState = mutableStateOf(false) ) { - when (message.getCalculateMessageType()) { + when (message.type) { ChatMessage.MessageType.REGULAR_TEXT_MESSAGE -> { - if (message.isLinkPreview()) { + if (message.isLinkPreview) { LinkMessage( message = message, conversationThreadId = conversationThreadId, @@ -30,7 +31,7 @@ fun ChatMessage( ) } else { TextMessage( - message = message, + uiMessage = message, showAvatar = showAvatar, conversationThreadId = conversationThreadId, state = isBlinkingState @@ -39,9 +40,9 @@ fun ChatMessage( } ChatMessage.MessageType.SYSTEM_MESSAGE -> { - if (!message.shouldFilter()) { + // if (!message.shouldFilter()) { SystemMessage(message) - } + // } } ChatMessage.MessageType.VOICE_MESSAGE -> { @@ -90,13 +91,13 @@ fun ChatMessage( } } -private fun ChatMessage.shouldFilter(): Boolean = - systemMessageType in setOf( - ChatMessage.SystemMessageType.REACTION, - ChatMessage.SystemMessageType.REACTION_DELETED, - ChatMessage.SystemMessageType.REACTION_REVOKED, - ChatMessage.SystemMessageType.POLL_VOTED, - ChatMessage.SystemMessageType.MESSAGE_EDITED, - ChatMessage.SystemMessageType.THREAD_CREATED - ) || - (parentMessageId != null && systemMessageType == ChatMessage.SystemMessageType.MESSAGE_DELETED) +// private fun ChatMessage.shouldFilter(): Boolean = +// systemMessageType in setOf( +// ChatMessage.SystemMessageType.REACTION, +// ChatMessage.SystemMessageType.REACTION_DELETED, +// ChatMessage.SystemMessageType.REACTION_REVOKED, +// ChatMessage.SystemMessageType.POLL_VOTED, +// ChatMessage.SystemMessageType.MESSAGE_EDITED, +// ChatMessage.SystemMessageType.THREAD_CREATED +// ) || +// (parentMessageId != null && systemMessageType == ChatMessage.SystemMessageType.MESSAGE_DELETED) diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatUiMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatUiMessage.kt deleted file mode 100644 index b150cdf0cf5..00000000000 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatUiMessage.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2026 Your Name - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package com.nextcloud.talk.ui.chat - -import androidx.compose.runtime.Immutable -import com.nextcloud.talk.chat.data.model.ChatMessage - -@Immutable -data class ChatUiMessage(val message: ChatMessage, val avatarUrl: String?) 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 index 8282c2ba3ce..49fa1ff0d5b 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -11,10 +11,7 @@ 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.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -27,7 +24,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -44,16 +40,10 @@ 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.draw.shadow -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.nextcloud.talk.R -import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.viewmodels.ChatViewModel -import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.ui.theme.LocalViewThemeUtils import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -87,7 +77,7 @@ fun GetNewChatView( val lastNewestIdRef = remember { object { - var value: String? = null + var value: Int? = null } } @@ -178,7 +168,7 @@ fun GetNewChatView( )?.let { item -> when (item) { is ChatViewModel.ChatItem.MessageItem -> - formatTime(item.message.timestamp * LONG_1000) + formatTime(item.uiMessage.timestamp * LONG_1000) is ChatViewModel.ChatItem.DateHeaderItem -> formatTime(item.date) @@ -220,7 +210,7 @@ fun GetNewChatView( // 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.message.jsonMessageId) + advanceLocalLastReadMessageIfNeeded?.invoke(item.uiMessage.id) } } } @@ -245,8 +235,8 @@ fun GetNewChatView( when (chatItem) { is ChatViewModel.ChatItem.MessageItem -> { val isBlinkingState = remember { mutableStateOf(false) } - ChatMessage( - message = chatItem.message, + ChatMessageView( + message = chatItem.uiMessage, showAvatar = showAvatar, conversationThreadId = conversationThreadId, isBlinkingState = isBlinkingState @@ -258,7 +248,7 @@ fun GetNewChatView( } is ChatViewModel.ChatItem.UnreadMessagesMarkerItem -> { - UnreadMessagesMarker(chatItem.date) + UnreadMessagesMarker() } } } @@ -378,7 +368,7 @@ fun DateHeader(date: LocalDate) { } @Composable -fun UnreadMessagesMarker(date: LocalDate) { +fun UnreadMessagesMarker() { val viewThemeUtils = LocalViewThemeUtils.current val colorScheme = viewThemeUtils.getColorScheme(LocalContext.current) @@ -401,69 +391,6 @@ fun UnreadMessagesMarker(date: LocalDate) { } } -@Deprecated("do not use Compose Chat Adapter") -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun GetView(messages: List, messageIdToBlink: String, user: User?) { - val listState = rememberLazyListState() - val isBlinkingState = remember { mutableStateOf(true) } - - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - state = listState, - modifier = Modifier.padding(16.dp) - ) { - stickyHeader { - if (messages.isEmpty()) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize() - ) { - ShimmerGroup() - } - } else { - val timestamp = messages[listState.firstVisibleItemIndex].timestamp - val dateString = formatTime(timestamp * LONG_1000) - val color = colorScheme.onSurfaceVariant - 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(messages) { message -> - val incoming = message.actorId != user?.userId - - ChatMessage( - message = message, - showAvatar = true, - isBlinkingState = isBlinkingState - ) - } - } -} - fun formatTime(timestampMillis: Long): String { val instant = Instant.ofEpochMilli(timestampMillis) val dateTime = instant.atZone(ZoneId.systemDefault()).toLocalDate() 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 index 1e762274379..8a46ae721b3 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt @@ -7,57 +7,45 @@ 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.runtime.MutableState -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -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.data.model.ChatMessage +import com.nextcloud.talk.chat.ui.model.ChatMessageUi private const val AUTHOR_TEXT_SIZE = 12 @Composable -fun DeckMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { - CommonMessageBody( - message = message, +fun DeckMessage(message: ChatMessageUi, conversationThreadId: Long? = null, state: MutableState) { + MessageScaffold( + uiMessage = message, conversationThreadId = conversationThreadId, playAnimation = state.value, content = { - Column { - if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { - for (key in message.messageParameters!!.keys) { - val individualHashMap: Map = message.messageParameters!![key]!! - if (individualHashMap["type"] == "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.sp, fontWeight = FontWeight.Bold) - } - Text(cardDescription, fontSize = AUTHOR_TEXT_SIZE.sp) - } - } - } - } - } + // Column { + // if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { + // for (key in message.messageParameters!!.keys) { + // val individualHashMap: Map = message.messageParameters!![key]!! + // if (individualHashMap["type"] == "deck-card") { + // val cardName = individualHashMap["name"] + // val stackName = individualHashMap["stackname"] + // val boardName = individualHashMap["boardname"] + // // val cardLink = individualHashMap["link"] + // + // 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.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 index 5fe8a4d61f5..c36ae612acc 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/GeolocationMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/GeolocationMessage.kt @@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView -import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.chat.ui.model.ChatMessageUi import org.osmdroid.config.Configuration import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.util.GeoPoint @@ -24,31 +24,31 @@ import org.osmdroid.views.overlay.Marker private const val MAP_ZOOM = 15.0 @Composable -fun GeolocationMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { - CommonMessageBody( - message = message, - conversationThreadId = conversationThreadId, - playAnimation = state.value, - content = { - Column { - if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { - for (key in message.messageParameters!!.keys) { - val individualHashMap: Map = message.messageParameters!![key]!! - if (individualHashMap["type"] == "geo-location") { - val lat = individualHashMap["latitude"] - val lng = individualHashMap["longitude"] - - if (lat != null && lng != null) { - val latitude = lat.toDouble() - val longitude = lng.toDouble() - OpenStreetMap(latitude, longitude) - } - } - } - } - } - } - ) +fun GeolocationMessage(message: ChatMessageUi, conversationThreadId: Long? = null, state: MutableState) { + // MessageScaffold( + // uiMessage = message, + // conversationThreadId = conversationThreadId, + // playAnimation = state.value, + // content = { + // Column { + // if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { + // for (key in message.messageParameters!!.keys) { + // val individualHashMap: Map = message.messageParameters!![key]!! + // if (individualHashMap["type"] == "geo-location") { + // val lat = individualHashMap["latitude"] + // val lng = individualHashMap["longitude"] + // + // if (lat != null && lng != null) { + // val latitude = lat.toDouble() + // val longitude = lng.toDouble() + // OpenStreetMap(latitude, longitude) + // } + // } + // } + // } + // } + // } + // ) } @Composable diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt index 19bc0ab439d..3187f7fc3d1 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt @@ -30,50 +30,50 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.nextcloud.talk.R import com.nextcloud.talk.adapters.messages.PreviewMessageViewHolder -import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.chat.ui.model.ChatMessageUi import com.nextcloud.talk.contacts.load import com.nextcloud.talk.models.json.chat.ReadStatus import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DrawableUtils @Composable -fun ImageMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { +fun ImageMessage(message: ChatMessageUi, conversationThreadId: Long? = null, state: MutableState) { val hasCaption = (message.message != "{file}") val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) - CommonMessageBody( - message = message, - conversationThreadId = conversationThreadId, - includePadding = false, - playAnimation = state.value, - content = { - Column { - // message.activeUser = adapter.currentUser - val imageUri = message.imageUrl - val mimetype = message.selectedIndividualHashMap!![PreviewMessageViewHolder.KEY_MIMETYPE] - val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(mimetype) - val loadedImage = load(imageUri, LocalContext.current, drawableResourceId) - - AsyncImage( - model = loadedImage, - contentDescription = stringResource(R.string.nc_sent_an_image), - modifier = Modifier - .fillMaxWidth(), - contentScale = ContentScale.FillWidth - ) - - if (hasCaption) { - Text( - message.text, - fontSize = 12.sp, - modifier = Modifier - .widthIn(20.dp, 140.dp) - .padding(8.dp) - ) - } - } - } - ) + // MessageScaffold( + // uiMessage = message, + // conversationThreadId = conversationThreadId, + // includePadding = false, + // playAnimation = state.value, + // content = { + // Column { + // // message.activeUser = adapter.currentUser + // val imageUri = message.imageUrl + // val mimetype = message.selectedIndividualHashMap!![PreviewMessageViewHolder.KEY_MIMETYPE] + // val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(mimetype) + // val loadedImage = load(imageUri, LocalContext.current, drawableResourceId) + // + // AsyncImage( + // model = loadedImage, + // contentDescription = stringResource(R.string.nc_sent_an_image), + // modifier = Modifier + // .fillMaxWidth(), + // contentScale = ContentScale.FillWidth + // ) + // + // if (hasCaption) { + // Text( + // message.text, + // fontSize = 12.sp, + // modifier = Modifier + // .widthIn(20.dp, 140.dp) + // .padding(8.dp) + // ) + // } + // } + // } + // ) if (!hasCaption) { Row(modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { @@ -92,17 +92,17 @@ fun ImageMessage(message: ChatMessage, conversationThreadId: Long? = null, state .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) - ) - } + // if (message.readStatus == ReadStatus.NONE) { + // val read = painterResource(R.drawable.ic_check_all) + // Icon( + // read, + // "", + // modifier = Modifier + // .padding(start = 4.dp) + // .size(16.dp) + // .align(Alignment.CenterVertically) + // ) + // } } } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt index 95f966678b6..2be56cccaef 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt @@ -10,13 +10,15 @@ package com.nextcloud.talk.ui.chat import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.viewmodels.ChatViewModel private const val REGULAR_TEXT_SIZE = 16 private const val AUTHOR_TEXT_SIZE = 12 private const val TIME_TEXT_SIZE = 12 @Composable -fun LinkMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { +fun LinkMessage(message: ChatMessageUi, conversationThreadId: Long? = null, state: MutableState) { // val color = colorResource(R.color.high_emphasis_text) // adapter.viewModel.chatViewModel.getOpenGraph( // adapter.currentUser.getCredentials(), 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 index 3abe426b626..7086d03f4ed 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/PollMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/PollMessage.kt @@ -23,38 +23,38 @@ 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.data.model.ChatMessage +import com.nextcloud.talk.chat.ui.model.ChatMessageUi private const val AUTHOR_TEXT_SIZE = 12 @Composable -fun PollMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { - CommonMessageBody( - message = message, - conversationThreadId = conversationThreadId, - playAnimation = state.value, - content = { - 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.sp, fontWeight = FontWeight.Bold) - } - - TextButtonNoStyling(stringResource(R.string.message_poll_tap_to_open)) { - // NOTE: read only for now - } - } - } - } - } - } - ) +fun PollMessage(message: ChatMessageUi, conversationThreadId: Long? = null, state: MutableState) { + // MessageScaffold( + // uiMessage = message, + // conversationThreadId = conversationThreadId, + // playAnimation = state.value, + // content = { + // 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.sp, fontWeight = FontWeight.Bold) + // } + // + // TextButtonNoStyling(stringResource(R.string.message_poll_tap_to_open)) { + // // NOTE: read only for now + // } + // } + // } + // } + // } + // } + // ) } @Composable 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 index 0aac00bebf0..a5424f4398f 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.unit.sp import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.chat.ui.model.ChatMessageUi import com.nextcloud.talk.utils.DateUtils private const val AUTHOR_TEXT_SIZE = 12 @@ -26,38 +27,38 @@ private const val TIME_TEXT_SIZE = 12 private const val FLOAT_06 = 0.6f @Composable -fun SystemMessage(message: ChatMessage) { - val similarMessages = NextcloudTalkApplication.sharedApplication!!.resources.getQuantityString( - R.plurals.see_similar_system_messages, - message.expandableChildrenAmount, - message.expandableChildrenAmount - ) - Column(horizontalAlignment = Alignment.CenterHorizontally) { - val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) - Row(horizontalArrangement = Arrangement.Absolute.Center, verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Modifier.weight(1f)) - Text( - message.text, - fontSize = AUTHOR_TEXT_SIZE.sp, - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(FLOAT_06) - ) - Text( - timeString, - fontSize = TIME_TEXT_SIZE.sp, - textAlign = TextAlign.End, - modifier = Modifier.align(Alignment.CenterVertically) - ) - Spacer(modifier = Modifier.weight(1f)) - } - - if (message.expandableChildrenAmount > 0) { - TextButtonNoStyling(similarMessages) { - // NOTE: Read only for now - } - } - } +fun SystemMessage(message: ChatMessageUi) { + // val similarMessages = NextcloudTalkApplication.sharedApplication!!.resources.getQuantityString( + // R.plurals.see_similar_system_messages, + // message.expandableChildrenAmount, + // message.expandableChildrenAmount + // ) + // Column(horizontalAlignment = Alignment.CenterHorizontally) { + // val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) + // Row(horizontalArrangement = Arrangement.Absolute.Center, verticalAlignment = Alignment.CenterVertically) { + // Spacer(modifier = Modifier.weight(1f)) + // Text( + // message.text, + // fontSize = AUTHOR_TEXT_SIZE.sp, + // modifier = Modifier + // .padding(8.dp) + // .fillMaxWidth(FLOAT_06) + // ) + // Text( + // timeString, + // fontSize = TIME_TEXT_SIZE.sp, + // textAlign = TextAlign.End, + // modifier = Modifier.align(Alignment.CenterVertically) + // ) + // Spacer(modifier = Modifier.weight(1f)) + // } + // + // if (message.expandableChildrenAmount > 0) { + // TextButtonNoStyling(similarMessages) { + // // NOTE: Read only for now + // } + // } + // } } @Composable 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 index 476a77d857e..3566b10f30b 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt @@ -9,23 +9,23 @@ package com.nextcloud.talk.ui.chat import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.chat.ui.model.ChatMessageUi @Composable fun TextMessage( - message: ChatMessage, + uiMessage: ChatMessageUi, showAvatar: Boolean, conversationThreadId: Long? = null, state: MutableState ) { - CommonMessageBody( - message = message, + MessageScaffold( + uiMessage = uiMessage, conversationThreadId = conversationThreadId, showAvatar = showAvatar, playAnimation = state.value, content = { EnrichedText( - message + uiMessage ) } ) diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt index 5bd28c37c19..3b80a39ac59 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt @@ -22,16 +22,16 @@ 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.data.model.ChatMessage +import com.nextcloud.talk.chat.ui.model.ChatMessageUi import com.nextcloud.talk.ui.WaveformSeekBar import kotlin.random.Random private const val DEFAULT_WAVE_SIZE = 50 @Composable -fun VoiceMessage(message: ChatMessage, conversationThreadId: Long? = null, state: MutableState) { - CommonMessageBody( - message = message, +fun VoiceMessage(message: ChatMessageUi, conversationThreadId: Long? = null, state: MutableState) { + MessageScaffold( + uiMessage = message, conversationThreadId = conversationThreadId, playAnimation = state.value, content = { 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/test/java/com/nextcloud/talk/json/ConversationConversionTest.kt b/app/src/test/java/com/nextcloud/talk/json/ConversationConversionTest.kt index c53ad1c7740..5e671d51209 100644 --- a/app/src/test/java/com/nextcloud/talk/json/ConversationConversionTest.kt +++ b/app/src/test/java/com/nextcloud/talk/json/ConversationConversionTest.kt @@ -9,7 +9,7 @@ package com.nextcloud.talk.json import com.bluelinelabs.logansquare.LoganSquare 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.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.conversations.RoomOverall @@ -54,7 +54,7 @@ class ConversationConversionTest(private val jsonFileName: String) { checkConversationEntity(conversationEntity, apiVersion) - val conversationModel = conversationEntity.asModel() + val conversationModel = conversationEntity.toDomainModel() val conversationEntityConvertedBack = conversationModel.asEntity() checkConversationEntity(conversationEntityConvertedBack, apiVersion) From c9aed1661bee5ec1761300e2395c29fc8cd77652 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Sat, 21 Feb 2026 00:42:30 +0100 Subject: [PATCH 19/19] introduce MessageTypeContent for type-safe rendering Signed-off-by: Marcel Hibbe --- .../database/migrations/MigrationsTest.kt | 24 ++- .../talk/chat/data/model/ChatMessage.kt | 1 - .../talk/chat/ui/model/ChatMessageUi.kt | 183 +++++++++++++++--- .../talk/chat/viewmodels/ChatViewModel.kt | 25 +-- .../database/mappers/ChatMessageMapUtils.kt | 1 - .../talk/data/source/local/Migrations.kt | 12 +- .../talk/ui/chat/ChatMessageScaffold.kt | 85 ++++---- .../nextcloud/talk/ui/chat/ChatMessageView.kt | 88 ++++----- .../com/nextcloud/talk/ui/chat/ChatView.kt | 4 +- .../com/nextcloud/talk/ui/chat/DeckMessage.kt | 61 +++--- .../talk/ui/chat/GeolocationMessage.kt | 11 +- .../nextcloud/talk/ui/chat/ImageMessage.kt | 78 ++++---- .../com/nextcloud/talk/ui/chat/LinkMessage.kt | 10 +- .../com/nextcloud/talk/ui/chat/PollMessage.kt | 54 +++--- .../nextcloud/talk/ui/chat/SystemMessage.kt | 19 +- .../com/nextcloud/talk/ui/chat/TextMessage.kt | 5 +- .../nextcloud/talk/ui/chat/VoiceMessage.kt | 9 +- 17 files changed, 381 insertions(+), 289 deletions(-) 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 d8f2f11ca00..9b9f443d3a6 100644 --- a/app/src/androidTest/java/com/nextcloud/talk/data/database/migrations/MigrationsTest.kt +++ b/app/src/androidTest/java/com/nextcloud/talk/data/database/migrations/MigrationsTest.kt @@ -41,7 +41,8 @@ class MigrationsTest { isTemporary: Int, timestamp: Long ) { - db.execSQL(""" + db.execSQL( + """ INSERT INTO ChatMessages ( internalId, accountId, @@ -121,7 +122,8 @@ class MigrationsTest { NULL, 0 ) - """) + """ + ) } @Test @@ -203,11 +205,13 @@ class MigrationsTest { Migrations.MIGRATION_23_24 ) - val cursor = db.query(""" + val cursor = db.query( + """ SELECT internalId, isTemporary, timestamp FROM ChatMessages WHERE referenceId = 'ref1' - """) + """ + ) assertEquals(1, cursor.count) assertTrue(cursor.moveToFirst()) @@ -239,11 +243,13 @@ class MigrationsTest { Migrations.MIGRATION_23_24 ) - val cursor = db.query(""" + val cursor = db.query( + """ SELECT internalId, timestamp FROM ChatMessages WHERE referenceId = 'ref2' - """) + """ + ) assertEquals(1, cursor.count) assertTrue(cursor.moveToFirst()) @@ -273,9 +279,11 @@ class MigrationsTest { Migrations.MIGRATION_23_24 ) - val cursor = db.query(""" + val cursor = db.query( + """ SELECT COUNT(*) FROM ChatMessages WHERE referenceId IS NULL - """) + """ + ) assertTrue(cursor.moveToFirst()) val count = cursor.getInt(0) 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 d3130e3e69f..f3ab7d99f16 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt @@ -14,7 +14,6 @@ import android.util.Log import com.bluelinelabs.logansquare.annotation.JsonIgnore import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication -import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.data.database.model.SendStatus import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.chat.ChatUtils.Companion.getParsedMessage 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 index 206fd36f1a0..ce62a4eb8fa 100644 --- 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 @@ -7,76 +7,76 @@ package com.nextcloud.talk.chat.ui.model +import android.text.TextUtils import androidx.compose.runtime.Immutable import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.data.database.model.SendStatus +import com.nextcloud.talk.utils.DrawableUtils import java.time.LocalDate // immutable class for chat message UI. only val, no vars! @Immutable data class ChatMessageUi( val id: Int, - val type: ChatMessage.MessageType, val text: String, val message: String, // what is the difference between message and text? remove one? val renderMarkdown: Boolean, - val isLinkPreview: Boolean, val actorDisplayName: String, val isThread: Boolean, val threadTitle: String, val incoming: Boolean, val isDeleted: Boolean, val avatarUrl: String?, - val imageUrl: String?, val statusIcon: MessageStatusIcon, val timestamp: Long, - val date: LocalDate + val date: LocalDate, + val content: MessageTypeContent? ) +sealed interface MessageTypeContent { + object RegularText : MessageTypeContent + object SystemMessage : MessageTypeContent + + data class LinkPreview( + // TODO + val todo: String + ) : MessageTypeContent + + data class Image(val imageUrl: String, val drawableResourceId: Int) : MessageTypeContent + + data class Geolocation(val lat: Double, val lon: Double) : MessageTypeContent + + data class Poll(val pollId: String, val pollName: String) : MessageTypeContent + + data class Deck(val cardName: String, val stackName: String, val boardName: String, val cardLink: String) : + MessageTypeContent + + data class Voice( + // TODO + val todo: String + ) : MessageTypeContent +} + enum class MessageStatusIcon { FAILED, SENDING, READ, - SENT, -} - -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 + SENT } // Domain model (ChatMessage) to UI model (ChatMessageUi) -fun ChatMessage.toUiModel( - lastCommonReadMessageId: Int -): ChatMessageUi { - - return ChatMessageUi( +fun ChatMessage.toUiModel(chatMessage: ChatMessage, lastCommonReadMessageId: Int): ChatMessageUi = + ChatMessageUi( id = jsonMessageId, - type = getCalculateMessageType(), text = text, message = message.orEmpty(), // what is the difference between message and text? remove one? renderMarkdown = renderMarkdown == true, - isLinkPreview = isLinkPreview(), actorDisplayName = actorDisplayName.orEmpty(), threadTitle = threadTitle.orEmpty(), isThread = isThread, incoming = incoming, isDeleted = isDeleted, avatarUrl = avatarUrl, - imageUrl = imageUrl, statusIcon = resolveStatusIcon( jsonMessageId, lastCommonReadMessageId, @@ -84,6 +84,125 @@ fun ChatMessage.toUiModel( sendStatus ), timestamp = timestamp, - date = dateKey() + date = dateKey(), + content = getMessageTypeContent(chatMessage) + ) + +fun resolveStatusIcon( + jsonMessageId: Int, + lastCommonReadMessageId: Int, + isTemporary: Boolean, + sendStatus: SendStatus? +): MessageStatusIcon { + val status = if (sendStatus == SendStatus.FAILED) { + MessageStatusIcon.FAILED + } else if (isTemporary) { + MessageStatusIcon.SENDING + } else if (jsonMessageId <= lastCommonReadMessageId) { + MessageStatusIcon.READ + } else { + MessageStatusIcon.SENT + } + return status +} + +fun getMessageTypeContent(message: ChatMessage): MessageTypeContent? = + if (!TextUtils.isEmpty(message.systemMessage)) { + MessageTypeContent.SystemMessage + } else if (message.isVoiceMessage) { + getVoiceContent(message) + } else if (message.hasFileAttachment()) { + getImageContent(message) + } else if (message.hasGeoLocation()) { + getGeolocationContent(message) + } else if (message.isLinkPreview()) { + getLinkPreviewContent(message) + } else if (message.isPoll()) { + getPollContent(message) + } else if (message.isDeckCard()) { + getDeckContent(message) + } else { + MessageTypeContent.RegularText + } + +fun getLinkPreviewContent(message: ChatMessage): MessageTypeContent.LinkPreview = + MessageTypeContent.LinkPreview( + todo = "still todo..." ) + +fun getImageContent(message: ChatMessage): MessageTypeContent.Image { + val imageUri = message.imageUrl + val mimetype = message.selectedIndividualHashMap!!["mimetype"] + val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(mimetype) + + return MessageTypeContent.Image( + imageUri!!, + drawableResourceId + ) +} + +fun getGeolocationContent(message: ChatMessage): MessageTypeContent.Geolocation? { + if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { + for (key in message.messageParameters!!.keys) { + val individualHashMap: Map = message.messageParameters!![key]!! + if (individualHashMap["type"] == "geo-location") { + val lat = individualHashMap["latitude"] + val lng = individualHashMap["longitude"] + + if (lat != null && lng != null) { + val latitude = lat.toDouble() + val longitude = lng.toDouble() + return MessageTypeContent.Geolocation( + lat = latitude, + lon = longitude + ) + } + } + } + } + return null +} + +fun getPollContent(message: ChatMessage): MessageTypeContent.Poll? { + if (message.messageParameters != null && message.messageParameters!!.size > 0) { + for (key in message.messageParameters!!.keys) { + val individualHashMap: Map = message.messageParameters!![key]!! + if (individualHashMap["type"] == "talk-poll") { + val pollId = individualHashMap["id"] + val pollName = individualHashMap["name"].toString() + return MessageTypeContent.Poll( + pollId = pollId!!, + pollName = pollName + ) + } + } + } + return null +} + +fun getDeckContent(message: ChatMessage): MessageTypeContent.Deck? { + if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { + for (key in message.messageParameters!!.keys) { + val individualHashMap: Map = message.messageParameters!![key]!! + if (individualHashMap["type"] == "deck-card") { + val cardName = individualHashMap["name"] + val stackName = individualHashMap["stackname"] + val boardName = individualHashMap["boardname"] + val cardLink = individualHashMap["link"] + + return MessageTypeContent.Deck( + cardName = cardName!!, + stackName = stackName!!, + boardName = boardName!!, + cardLink = cardLink!! + ) + } + } + } + return null } + +fun getVoiceContent(message: ChatMessage): MessageTypeContent.Voice = + MessageTypeContent.Voice( + todo = "still todo..." + ) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index b20acdc5b63..d9f60861bbc 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 @@ -381,9 +381,7 @@ class ChatViewModel @AssistedInject constructor( // ------------------------------ // Messages // ------------------------------ - private fun Flow>.mapToChatMessages( - userId: String - ): Flow> = + private fun Flow>.mapToChatMessages(userId: String): Flow> = map { entities -> entities.map { entity -> entity.toDomainModel().apply { @@ -445,7 +443,12 @@ class ChatViewModel @AssistedInject constructor( // ------------------------------ private fun observeMessages() { combine(messagesFlow, getLastCommonReadFlow) { messages, lastRead -> - messages.map { it.toUiModel(lastRead) } + messages.map { + it.toUiModel( + it, + lastRead + ) + } } .onEach { messages -> val items = buildChatItems(messages, lastReadMessage) @@ -475,10 +478,7 @@ class ChatViewModel @AssistedInject constructor( // ------------------------------ // Build chat items (pure) // ------------------------------ - private fun buildChatItems( - uiMessages: List, - lastReadMessage: Int - ): List { + private fun buildChatItems(uiMessages: List, lastReadMessage: Int): List { var lastDate: LocalDate? = null return buildList { @@ -628,8 +628,6 @@ class ChatViewModel @AssistedInject constructor( return chatMessageMap.values.toList() } - - // val timeString = DateUtils.getLocalTimeStringFromTimestamp(message.timestamp) fun getAvatarUrl(message: ChatMessage): String = @@ -892,7 +890,8 @@ class ChatViewModel @AssistedInject constructor( val messageId = currentItems .asReversed() - .firstNotNullOfOrNull { item -> (item as? ChatItem.MessageItem)?.uiMessage?.id + .firstNotNullOfOrNull { item -> + (item as? ChatItem.MessageItem)?.uiMessage?.id } Log.d(TAG, "Compose load more, messageId: $messageId") @@ -1532,10 +1531,6 @@ class ChatViewModel @AssistedInject constructor( data class Error(val throwable: Throwable) : ChatEvent() } - - - - sealed interface ChatItem { fun messageOrNull(): ChatMessageUi? = (this as? MessageItem)?.uiMessage fun dateOrNull(): LocalDate? = (this as? DateHeaderItem)?.date 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 e83832eca66..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( diff --git a/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt b/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt index 32de4edb035..75bf0ec0611 100644 --- a/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt +++ b/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt @@ -429,7 +429,8 @@ object Migrations { fun migrateToUniqueReferenceIds(db: SupportSQLiteDatabase) { // referenceId could exist multiple times (they should not, but just in case..). Before migrating to unique // index, make sure to delete all duplicates. - db.execSQL(""" + db.execSQL( + """ DELETE FROM ChatMessages WHERE rowid NOT IN ( -- Keep the latest non-temporary per referenceId @@ -443,13 +444,16 @@ object Migrations { FROM ChatMessages WHERE referenceId IS NULL ) - """) + """ + ) // Now it's safe to create the unique index - db.execSQL(""" + db.execSQL( + """ CREATE UNIQUE INDEX IF NOT EXISTS index_ChatMessages_referenceId ON ChatMessages(referenceId) - """) + """ + ) } } 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 index 1b4e808e647..914b204b3df 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding @@ -31,12 +30,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -206,46 +201,46 @@ fun MessageScaffold( } } -@Composable -fun CommonMessageQuote(context: Context, message: ChatMessageUi, incoming: Boolean) { - val color = colorResource(R.color.high_emphasis_text) - Row( - modifier = Modifier - .drawWithCache { - onDrawWithContent { - drawLine( - color = color, - start = Offset(0f, this.size.height / QUOTE_SHAPE_OFFSET), - end = Offset(0f, this.size.height - (this.size.height / QUOTE_SHAPE_OFFSET)), - strokeWidth = 4f, - cap = StrokeCap.Round - ) - - drawContent() - } - } - .padding(8.dp) - ) { - Column { - Text(message.actorDisplayName!!, fontSize = AUTHOR_TEXT_SIZE) - val imageUri = message.imageUrl - if (imageUri != null) { - val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image - val loadedImage = loadImage(imageUri, context, errorPlaceholderImage) - AsyncImage( - model = loadedImage, - contentDescription = stringResource(R.string.nc_sent_an_image), - modifier = Modifier - .padding(8.dp) - .fillMaxHeight() - ) - } - EnrichedText( - message - ) - } - } -} +// @Composable +// fun CommonMessageQuote(context: Context, message: ChatMessageUi, incoming: Boolean) { +// val color = colorResource(R.color.high_emphasis_text) +// Row( +// modifier = Modifier +// .drawWithCache { +// onDrawWithContent { +// drawLine( +// color = color, +// start = Offset(0f, this.size.height / QUOTE_SHAPE_OFFSET), +// end = Offset(0f, this.size.height - (this.size.height / QUOTE_SHAPE_OFFSET)), +// strokeWidth = 4f, +// cap = StrokeCap.Round +// ) +// +// drawContent() +// } +// } +// .padding(8.dp) +// ) { +// Column { +// Text(message.actorDisplayName!!, fontSize = AUTHOR_TEXT_SIZE) +// val imageUri = message.imageUrl +// if (imageUri != null) { +// val errorPlaceholderImage: Int = R.drawable.ic_mimetype_image +// val loadedImage = loadImage(imageUri, context, errorPlaceholderImage) +// AsyncImage( +// model = loadedImage, +// contentDescription = stringResource(R.string.nc_sent_an_image), +// modifier = Modifier +// .padding(8.dp) +// .fillMaxHeight() +// ) +// } +// EnrichedText( +// message +// ) +// } +// } +// } private fun getColorFromTheme(context: Context, resourceId: Int): Int { val isDarkMode = DisplayUtils.isAppThemeDarkMode(context) 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 index a4231a1841a..839f1ede723 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt @@ -9,79 +9,72 @@ package com.nextcloud.talk.ui.chat import android.util.Log import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageTypeContent @Composable fun ChatMessageView( message: ChatMessageUi, showAvatar: Boolean, - conversationThreadId: Long? = null, - isBlinkingState: MutableState = mutableStateOf(false) + conversationThreadId: Long? = null ) { - when (message.type) { - ChatMessage.MessageType.REGULAR_TEXT_MESSAGE -> { - if (message.isLinkPreview) { - LinkMessage( - message = message, - conversationThreadId = conversationThreadId, - state = isBlinkingState - ) - } else { - TextMessage( - uiMessage = message, - showAvatar = showAvatar, - conversationThreadId = conversationThreadId, - state = isBlinkingState - ) - } + when (val content = message.content) { + MessageTypeContent.RegularText -> { + TextMessage( + uiMessage = message, + showAvatar = showAvatar, + conversationThreadId = conversationThreadId + ) } - ChatMessage.MessageType.SYSTEM_MESSAGE -> { - // if (!message.shouldFilter()) { - SystemMessage(message) - // } + MessageTypeContent.SystemMessage -> { + SystemMessage(message) } - ChatMessage.MessageType.VOICE_MESSAGE -> { - VoiceMessage( + is MessageTypeContent.Image -> { + ImageMessage( + typeContent = content, message = message, - conversationThreadId = conversationThreadId, - state = isBlinkingState + conversationThreadId = conversationThreadId ) } - ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE -> { - ImageMessage( + is MessageTypeContent.LinkPreview -> { + LinkMessage( + typeContent = content, message = message, - conversationThreadId = conversationThreadId, - state = isBlinkingState + conversationThreadId = conversationThreadId ) } - ChatMessage.MessageType.SINGLE_NC_GEOLOCATION_MESSAGE -> { + is MessageTypeContent.Geolocation -> { GeolocationMessage( + typeContent = content, + message = message + ) + } + + is MessageTypeContent.Voice -> { + VoiceMessage( + typeContent = content, message = message, - conversationThreadId = conversationThreadId, - state = isBlinkingState + conversationThreadId = conversationThreadId ) } - ChatMessage.MessageType.POLL_MESSAGE -> { + is MessageTypeContent.Poll -> { PollMessage( + typeContent = content, message = message, - conversationThreadId = conversationThreadId, - state = isBlinkingState + conversationThreadId = conversationThreadId ) } - ChatMessage.MessageType.DECK_CARD -> { + is MessageTypeContent.Deck -> { DeckMessage( + typeContent = content, message = message, - conversationThreadId = conversationThreadId, - state = isBlinkingState + conversationThreadId = conversationThreadId ) } @@ -90,14 +83,3 @@ fun ChatMessageView( } } } - -// private fun ChatMessage.shouldFilter(): Boolean = -// systemMessageType in setOf( -// ChatMessage.SystemMessageType.REACTION, -// ChatMessage.SystemMessageType.REACTION_DELETED, -// ChatMessage.SystemMessageType.REACTION_REVOKED, -// ChatMessage.SystemMessageType.POLL_VOTED, -// ChatMessage.SystemMessageType.MESSAGE_EDITED, -// ChatMessage.SystemMessageType.THREAD_CREATED -// ) || -// (parentMessageId != null && systemMessageType == ChatMessage.SystemMessageType.MESSAGE_DELETED) 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 index 49fa1ff0d5b..b1fc1ba1867 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -234,12 +234,10 @@ fun GetNewChatView( items(chatItems, key = { it.stableKey() }) { chatItem -> when (chatItem) { is ChatViewModel.ChatItem.MessageItem -> { - val isBlinkingState = remember { mutableStateOf(false) } ChatMessageView( message = chatItem.uiMessage, showAvatar = showAvatar, - conversationThreadId = conversationThreadId, - isBlinkingState = isBlinkingState + conversationThreadId = conversationThreadId ) } 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 index 8a46ae721b3..c4ff321ed11 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt @@ -7,45 +7,48 @@ 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.runtime.MutableState +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(message: ChatMessageUi, conversationThreadId: Long? = null, state: MutableState) { +fun DeckMessage(typeContent: MessageTypeContent.Deck, message: ChatMessageUi, conversationThreadId: Long? = null) { MessageScaffold( uiMessage = message, conversationThreadId = conversationThreadId, - playAnimation = state.value, content = { - // Column { - // if (message.messageParameters != null && message.messageParameters!!.isNotEmpty()) { - // for (key in message.messageParameters!!.keys) { - // val individualHashMap: Map = message.messageParameters!![key]!! - // if (individualHashMap["type"] == "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.sp, fontWeight = FontWeight.Bold) - // } - // Text(cardDescription, fontSize = AUTHOR_TEXT_SIZE.sp) - // } - // } - // } - // } - // } + 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 index c36ae612acc..aa4837a2979 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/GeolocationMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/GeolocationMessage.kt @@ -7,14 +7,13 @@ package com.nextcloud.talk.ui.chat -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageTypeContent import org.osmdroid.config.Configuration import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.util.GeoPoint @@ -24,7 +23,13 @@ import org.osmdroid.views.overlay.Marker private const val MAP_ZOOM = 15.0 @Composable -fun GeolocationMessage(message: ChatMessageUi, conversationThreadId: Long? = null, state: MutableState) { +fun GeolocationMessage( + typeContent: MessageTypeContent.Geolocation, + message: ChatMessageUi +) { + typeContent.lat + typeContent.lon + // MessageScaffold( // uiMessage = message, // conversationThreadId = conversationThreadId, diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt index 3187f7fc3d1..85067f83384 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ImageMessage.kt @@ -14,66 +14,64 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.nextcloud.talk.R -import com.nextcloud.talk.adapters.messages.PreviewMessageViewHolder 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.chat.ReadStatus import com.nextcloud.talk.utils.DateUtils -import com.nextcloud.talk.utils.DrawableUtils @Composable -fun ImageMessage(message: ChatMessageUi, conversationThreadId: Long? = null, state: MutableState) { +fun ImageMessage( + typeContent: MessageTypeContent.Image, + message: ChatMessageUi, + conversationThreadId: Long? = null +) { val hasCaption = (message.message != "{file}") val timeString = DateUtils(LocalContext.current).getLocalTimeStringFromTimestamp(message.timestamp) - // MessageScaffold( - // uiMessage = message, - // conversationThreadId = conversationThreadId, - // includePadding = false, - // playAnimation = state.value, - // content = { - // Column { - // // message.activeUser = adapter.currentUser - // val imageUri = message.imageUrl - // val mimetype = message.selectedIndividualHashMap!![PreviewMessageViewHolder.KEY_MIMETYPE] - // val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(mimetype) - // val loadedImage = load(imageUri, LocalContext.current, drawableResourceId) - // - // AsyncImage( - // model = loadedImage, - // contentDescription = stringResource(R.string.nc_sent_an_image), - // modifier = Modifier - // .fillMaxWidth(), - // contentScale = ContentScale.FillWidth - // ) - // - // if (hasCaption) { - // Text( - // message.text, - // fontSize = 12.sp, - // modifier = Modifier - // .widthIn(20.dp, 140.dp) - // .padding(8.dp) - // ) - // } - // } - // } - // ) + MessageScaffold( + uiMessage = message, + conversationThreadId = conversationThreadId, + includePadding = false, + content = { + Column { + val loadedImage = load( + imageUri = typeContent.imageUrl, + context = LocalContext.current, + errorPlaceholderImage = typeContent.drawableResourceId + ) + + AsyncImage( + model = loadedImage, + contentDescription = stringResource(R.string.nc_sent_an_image), + modifier = Modifier + .fillMaxWidth(), + contentScale = ContentScale.FillWidth + ) + + if (hasCaption) { + Text( + message.text, + fontSize = 12.sp, + modifier = Modifier + .widthIn(20.dp, 140.dp) + .padding(8.dp) + ) + } + } + } + ) if (!hasCaption) { Row(modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { 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 index 2be56cccaef..3adec68ffc9 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/LinkMessage.kt @@ -8,17 +8,19 @@ package com.nextcloud.talk.ui.chat import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.ui.model.ChatMessageUi -import com.nextcloud.talk.chat.viewmodels.ChatViewModel +import com.nextcloud.talk.chat.ui.model.MessageTypeContent private const val REGULAR_TEXT_SIZE = 16 private const val AUTHOR_TEXT_SIZE = 12 private const val TIME_TEXT_SIZE = 12 @Composable -fun LinkMessage(message: ChatMessageUi, conversationThreadId: Long? = null, state: MutableState) { +fun LinkMessage( + typeContent: MessageTypeContent.LinkPreview, + message: ChatMessageUi, + conversationThreadId: Long? = null +) { // val color = colorResource(R.color.high_emphasis_text) // adapter.viewModel.chatViewModel.getOpenGraph( // adapter.currentUser.getCredentials(), 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 index 7086d03f4ed..5375b7576f6 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/PollMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/PollMessage.kt @@ -14,7 +14,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource @@ -24,37 +23,36 @@ 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(message: ChatMessageUi, conversationThreadId: Long? = null, state: MutableState) { - // MessageScaffold( - // uiMessage = message, - // conversationThreadId = conversationThreadId, - // playAnimation = state.value, - // content = { - // 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.sp, fontWeight = FontWeight.Bold) - // } - // - // TextButtonNoStyling(stringResource(R.string.message_poll_tap_to_open)) { - // // NOTE: read only for now - // } - // } - // } - // } - // } - // } - // ) +fun PollMessage( + typeContent: MessageTypeContent.Poll, + message: ChatMessageUi, + conversationThreadId: Long? = null +) { + MessageScaffold( + uiMessage = message, + conversationThreadId = conversationThreadId, + content = { + Column { + Row(modifier = Modifier.padding(start = 8.dp)) { + Icon(painterResource(R.drawable.ic_baseline_bar_chart_24), "") + Text( + typeContent.pollName, + fontSize = AUTHOR_TEXT_SIZE.sp, + fontWeight = FontWeight.Bold + ) + } + + TextButtonNoStyling(stringResource(R.string.message_poll_tap_to_open)) { + // NOTE: read only for now + } + } + } + ) } @Composable 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 index a5424f4398f..1f473081983 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/SystemMessage.kt @@ -1,33 +1,20 @@ package com.nextcloud.talk.ui.chat -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding 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.graphics.Color -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.R -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.chat.data.model.ChatMessage 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 private const val FLOAT_06 = 0.6f @Composable -fun SystemMessage(message: ChatMessageUi) { +fun SystemMessage( + message: ChatMessageUi +) { // val similarMessages = NextcloudTalkApplication.sharedApplication!!.resources.getQuantityString( // R.plurals.see_similar_system_messages, // message.expandableChildrenAmount, 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 index 3566b10f30b..f6d44078a24 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/TextMessage.kt @@ -8,21 +8,18 @@ package com.nextcloud.talk.ui.chat import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import com.nextcloud.talk.chat.ui.model.ChatMessageUi @Composable fun TextMessage( uiMessage: ChatMessageUi, showAvatar: Boolean, - conversationThreadId: Long? = null, - state: MutableState + conversationThreadId: Long? = null ) { MessageScaffold( uiMessage = uiMessage, conversationThreadId = conversationThreadId, showAvatar = showAvatar, - playAnimation = state.value, content = { EnrichedText( uiMessage diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt index 3b80a39ac59..ec5cc9557b9 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt @@ -16,24 +16,27 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.ui.WaveformSeekBar import kotlin.random.Random private const val DEFAULT_WAVE_SIZE = 50 @Composable -fun VoiceMessage(message: ChatMessageUi, conversationThreadId: Long? = null, state: MutableState) { +fun VoiceMessage( + typeContent: MessageTypeContent.Voice, + message: ChatMessageUi, + conversationThreadId: Long? = null +) { MessageScaffold( uiMessage = message, conversationThreadId = conversationThreadId, - playAnimation = state.value, content = { val inversePrimary = colorScheme.inversePrimary.toArgb() val onPrimaryContainer = colorScheme.onPrimaryContainer.toArgb()