diff --git a/app/build.gradle b/app/build.gradle index 3ad4017cb58..0a4b4604772 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.2' 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 14c4e52f5a2..d4a94423cce 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -287,7 +287,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( @@ -1927,7 +1930,7 @@ class CallActivity : CallBaseActivity() { when (messageType) { "usersInRoom" -> - internalSignalingMessageReceiver.process(signaling.messageWrapper as List?>?) + internalSignalingMessageReceiver.process(signaling.messageWrapper as List>) "message" -> { val ncSignalingMessage = LoganSquare.parse( @@ -2743,11 +2746,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 34569344b4c..41bb1d4b5f4 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -61,8 +61,10 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ContextThemeWrapper import androidx.cardview.widget.CardView import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.coordinatorlayout.widget.CoordinatorLayout @@ -81,8 +83,11 @@ import androidx.core.view.WindowInsetsCompat import androidx.emoji2.text.EmojiCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.commit +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -141,6 +146,7 @@ import com.nextcloud.talk.contextchat.ContextChatViewModel import com.nextcloud.talk.conversationinfo.ConversationInfoActivity import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationlist.ConversationsListActivity +import com.nextcloud.talk.dagger.modules.ViewModelFactoryWithParams import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ActivityChatBinding @@ -156,6 +162,7 @@ import com.nextcloud.talk.messagesearch.MessageSearchActivity import com.nextcloud.talk.models.ExternalSignalingServer import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.capabilities.SpreedCapability +import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.models.json.chat.ReadStatus import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.participants.Participant @@ -173,6 +180,7 @@ 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 +190,8 @@ import com.nextcloud.talk.ui.dialog.ShowReactionsDialog import com.nextcloud.talk.ui.dialog.TempMessageActionsDialog import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback +import com.nextcloud.talk.ui.theme.LocalMessageUtils +import com.nextcloud.talk.ui.theme.LocalViewThemeUtils import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.AudioUtils import com.nextcloud.talk.utils.CapabilitiesUtil @@ -232,7 +242,7 @@ 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 @@ -293,7 +303,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 +378,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 +403,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 +474,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 +522,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 +543,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 +571,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 +586,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 +621,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 +709,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 +754,52 @@ class ChatActivity : this.lifecycle.removeObserver(chatViewModel) } - @OptIn(ExperimentalCoroutinesApi::class) + @OptIn(FlowPreview::class) @SuppressLint("NotifyDataSetChanged", "SetTextI18n", "ResourceAsColor") @Suppress("LongMethod") private fun initObservers() { Log.d(TAG, "initObservers Called") + + lifecycleScope.launch { + 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 +978,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, @@ -1006,7 +1138,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) } @@ -1040,77 +1172,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 { @@ -1136,20 +1197,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) @@ -1529,6 +1576,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 @@ -2834,9 +2883,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 = @@ -2963,6 +3043,7 @@ class ChatActivity : private fun setupWebsocket() { if (currentConversation == null || conversationUser == null) { + Log.e(TAG, "setupWebsocket: currentConversation or conversationUser is null") return } @@ -3120,62 +3201,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() @@ -3202,26 +3283,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 { @@ -3229,38 +3310,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] @@ -3281,6 +3331,7 @@ class ChatActivity : previousMessageId = chatMessage.jsonMessageId } + return chatMessageList } private fun getItemFromAdapter(messageId: String): Pair? { @@ -3289,7 +3340,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 { @@ -3318,6 +3369,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 @@ -3325,6 +3434,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) @@ -3867,6 +3978,55 @@ class ChatActivity : return chatMessageMap.values.toList() } + private fun groupAndEnrichMessages(chatMessageList: List): List { + fun groupMessages(message1: ChatMessage, message2: ChatMessage): Boolean { + val message1IsSystem = message1.systemMessage.isNotEmpty() + val message2IsSystem = message2.systemMessage.isNotEmpty() + if (message1IsSystem != message2IsSystem) { + return false + } + + if (message1.actorType == "bots" && message1.actorId != "changelog") { + return false + } + + if (!message1IsSystem && + ( + (message1.actorType != message2.actorType) || + (message2.actorId != message1.actorId) + ) + ) { + return false + } + + val timeDifference = dateUtils.getTimeDifferenceInSeconds( + message2.timestamp, + message1.timestamp + ) + val isLessThan5Min = timeDifference > FIVE_MINUTES_IN_SECONDS + return isSameDayMessages(message2, message1) && + (message2.actorId == message1.actorId) && + (!isLessThan5Min) && + (message2.lastEditTimestamp == 0L || message1.lastEditTimestamp == 0L) + } + + for (i in chatMessageList.indices) { + if (chatMessageList.size > i + 1) { + chatMessageList[i].isGrouped = groupMessages(chatMessageList[i], chatMessageList[i + 1]) + } + val chatMessage = chatMessageList[i] + + chatMessage.isOneToOneConversation = + currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + chatMessage.isFormerOneToOneConversation = + (currentConversation?.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE) + + chatMessage.activeUser = conversationUser + chatMessage.token = roomToken + } + return chatMessageList + } + private fun groupSystemMessages(previousMessage: ChatMessage, currentMessage: ChatMessage) { previousMessage.expandableParent = true currentMessage.expandableParent = false @@ -4111,7 +4271,10 @@ class ChatActivity : binding.genericComposeView.apply { val shouldDismiss = mutableStateOf(false) setContent { - DateTimeCompose(bundle).GetDateTimeDialog(shouldDismiss, this@ChatActivity) + DateTimeCompose( + bundle, + chatViewModel + ).GetDateTimeDialog(shouldDismiss, this@ChatActivity) } } } @@ -4142,10 +4305,25 @@ class ChatActivity : chatViewModel.unPinMessage(credentials!!, url) } + private fun markAsRead(messageId: Int) { + chatViewModel.setChatReadMessage( + credentials!!, + ApiUtils.getUrlForChatReadMarker( + ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(ApiUtils.API_V1)), + conversationUser?.baseUrl!!, + roomToken + ), + messageId + ) + } + fun markAsUnread(message: IMessage?) { val chatMessage = message as ChatMessage? if (chatMessage!!.previousMessageId > NO_PREVIOUS_MESSAGE_ID) { - chatViewModel.setChatReadMarker( + // previousMessageId is taken to mark chat as unread even when "chat-unread" capability is not available + // It should be checked if "chat-unread" capability is available and then use + // https://nextcloud-talk.readthedocs.io/en/latest/chat/#mark-chat-as-unread + chatViewModel.setChatReadMessage( credentials!!, ApiUtils.getUrlForChatReadMarker( ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(ApiUtils.API_V1)), diff --git a/app/src/main/java/com/nextcloud/talk/chat/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..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 @@ -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,34 @@ 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 dateOrNull(): LocalDate? = (this as? DateHeaderItem)?.date + + 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 402b266a0b2..80d15774bbc 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) { @@ -1454,7 +1450,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/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..e1975024421 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -0,0 +1,504 @@ +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 +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 +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.mutableIntStateOf +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 +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.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private const val LONG_1000 = 1000L +private val AUTHOR_TEXT_SIZE = 12.sp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GetNewChatView( + chatItems: List, + conversationThreadId: Long? = null, + onLoadMore: (() -> Unit?)? +) { + val listState = rememberLazyListState() + val showUnreadPopup = remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + val lastNewestIdRef = remember { + 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 && + listState.firstVisibleItemScrollOffset == 0 + } + } + + val isNearNewest by remember(listState) { + derivedStateOf { + listState.firstVisibleItemIndex <= 2 + } + } + + // Show floating scroll-to-newest button when not at newest + val showScrollToNewest by remember { derivedStateOf { !isAtNewest } } + + // Track newest message and show unread popup + LaunchedEffect(chatItems) { + if (chatItems.isEmpty()) return@LaunchedEffect + + val newestId = chatItems.firstNotNullOfOrNull { it.messageOrNull()?.id } + val previousNewestId = lastNewestIdRef.value + + val isNearBottom = listState.firstVisibleItemIndex <= 2 + val hasNewMessage = previousNewestId != null && newestId != previousNewestId + + if (hasNewMessage) { + if (isNearBottom) { + listState.animateScrollToItem(0) + unreadCount = 0 + } else { + unreadCount++ + showUnreadPopup.value = true + } + } + + lastNewestIdRef.value = newestId + } + + // Hide unread popup when user scrolls to newest + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .map { it <= 2 } + .distinctUntilChanged() + .collect { nearBottom -> + if (nearBottom) { + showUnreadPopup.value = false + unreadCount = 0 + } + } + } + + // Load more when near end + LaunchedEffect(listState, chatItems.size) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val total = layoutInfo.totalItemsCount + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + lastVisible to total + } + .distinctUntilChanged() + .collect { (lastVisible, total) -> + if (total == 0) return@collect + + val buffer = 5 + val shouldLoadMore = lastVisible >= (total - 1 - buffer) + + if (shouldLoadMore) { + onLoadMore?.invoke() + } + } + } + + // Sticky date header + val stickyDateHeaderText by remember(listState, chatItems) { + derivedStateOf { + chatItems.getOrNull( + listState.layoutInfo.visibleItemsInfo + .lastOrNull()?.index ?: 0 + )?.let { item -> + when (item) { + is ChatViewModel.ChatItem.MessageItem -> + formatTime(item.message.timestamp * LONG_1000) + + is ChatViewModel.ChatItem.DateHeaderItem -> + formatTime(item.date) + } + } ?: "" + } + } + + var stickyDateHeader by remember { mutableStateOf(false) } + + LaunchedEffect(listState, isNearNewest) { + // Only listen to scroll if user is away from newest messages. This ensures the stickyHeader is not shown on + // every new received message when being at the bottom of the list (because this triggers a scroll). + if (!isNearNewest) { + snapshotFlow { listState.isScrollInProgress } + .collectLatest { scrolling -> + if (scrolling) { + stickyDateHeader = true + } else { + delay(1200) + stickyDateHeader = false + } + } + } else { + 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, + reverseLayout = true, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(bottom = 20.dp), + modifier = Modifier + .padding(start = 12.dp, end = 12.dp) + .fillMaxSize() + ) { + items(chatItems, key = { it.stableKey() }) { chatItem -> + when (chatItem) { + is ChatViewModel.ChatItem.MessageItem -> { + val isBlinkingState = remember { mutableStateOf(false) } + GetComposableForMessage( + message = chatItem.message, + conversationThreadId = conversationThreadId, + isBlinkingState = isBlinkingState + ) + } + + is ChatViewModel.ChatItem.DateHeaderItem -> { + DateHeader(chatItem.date) + } + } + } + } + + // Sticky date header + 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) + ) + } + + // Unread messages popup + if (showUnreadPopup.value) { + UnreadMessagesPopup( + unreadCount = unreadCount, + onClick = { + coroutineScope.launch { listState.scrollToItem(0) } + unreadCount = 0 + showUnreadPopup.value = false + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 20.dp) + ) + } + + // Floating scroll-to-newest button + AnimatedVisibility( + visible = showScrollToNewest, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 24.dp), + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut() + ) { + Surface( + onClick = { + coroutineScope.launch { listState.scrollToItem(0) } + unreadCount = 0 + }, + shape = CircleShape, + color = colorScheme.surface.copy(alpha = 0.9f), + tonalElevation = 2.dp + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Scroll to newest", + modifier = Modifier + .size(36.dp) + .padding(8.dp), + tint = colorScheme.onSurface.copy(alpha = 0.9f) + ) + } + } + } +} + +@Composable +fun UnreadMessagesPopup( + unreadCount: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + 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) { + 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 + ) + } +} + +@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 + + GetComposableForMessage( + message = message, + 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() + return formatTime(dateTime) +} + +fun formatTime(localDate: LocalDate): String { + val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy") + val text = when (localDate) { + LocalDate.now() -> "Today" + LocalDate.now().minusDays(1) -> "Yesterday" + else -> localDate.format(formatter) + } + return text +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/DeckMessage.kt new file mode 100644 index 00000000000..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" /> + +