From 06d1399731bdfacc5d6c0e7546f99f46957267cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BBerko?= Date: Fri, 22 Mar 2024 11:38:11 +0100 Subject: [PATCH] feat: message draft [WPB-1021] (#2796) --- .../android/di/accountScoped/MessageModule.kt | 18 ++ .../home/conversations/ConversationScreen.kt | 23 +- .../conversations/MessageComposerViewModel.kt | 13 ++ .../messages/draft/MessageDraftViewModel.kt | 84 +++++++ .../ui/home/conversations/model/UIMention.kt | 42 ++++ .../ui/home/conversations/model/UIMessage.kt | 35 --- .../conversations/model/UIQuotedMessage.kt | 92 ++++++++ .../GetQuoteMessageForConversationUseCase.kt | 72 ++++++ .../{UiMention.kt => MembersMentionList.kt} | 11 - .../home/messagecomposer/MessageComposer.kt | 5 +- .../messagecomposer/MessageComposerInput.kt | 4 +- .../model/MessageComposition.kt | 158 ++++++++++++++ .../state/MessageComposerStateHolder.kt | 15 +- .../state/MessageCompositionHolder.kt | 205 +++--------------- .../MessageCompositionInputStateHolder.kt | 1 + .../kotlin/com/wire/android/util/ui/UIText.kt | 24 +- .../MessageComposerViewModelArrangement.kt | 16 +- .../MessageComposerViewModelTest.kt | 87 +++++--- .../draft/MessageDraftViewModelTest.kt | 199 +++++++++++++++++ .../MessageComposerStateHolderTest.kt | 5 +- .../state/MessageCompositionHolderTest.kt | 9 +- .../MessageCompositionInputStateHolderTest.kt | 1 + 22 files changed, 853 insertions(+), 266 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMention.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIQuotedMessage.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetQuoteMessageForConversationUseCase.kt rename app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/{UiMention.kt => MembersMentionList.kt} (86%) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageComposition.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModelTest.kt diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt index 146c8377b28..ad72e7b2fea 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt @@ -45,6 +45,9 @@ import com.wire.kalium.logic.feature.message.SendLocationUseCase import com.wire.kalium.logic.feature.message.SendTextMessageUseCase import com.wire.kalium.logic.feature.message.ToggleReactionUseCase import com.wire.kalium.logic.feature.message.composite.SendButtonActionMessageUseCase +import com.wire.kalium.logic.feature.message.draft.GetMessageDraftUseCase +import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase +import com.wire.kalium.logic.feature.message.draft.SaveMessageDraftUseCase import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase import com.wire.kalium.logic.feature.message.getPaginatedFlowOfAssetMessageByConversationId import com.wire.kalium.logic.feature.message.getPaginatedFlowOfMessagesByConversation @@ -198,4 +201,19 @@ class MessageModule { @Provides fun provideObserveAssetStatusesUseCase(messageScope: MessageScope): ObserveAssetStatusesUseCase = messageScope.observeAssetStatuses + + @ViewModelScoped + @Provides + fun provideSaveMessageDraftUseCase(messageScope: MessageScope): SaveMessageDraftUseCase = + messageScope.saveMessageDraftUseCase + + @ViewModelScoped + @Provides + fun provideGetMessageDraftUseCase(messageScope: MessageScope): GetMessageDraftUseCase = + messageScope.getMessageDraftUseCase + + @ViewModelScoped + @Provides + fun provideRemoveMessageDraftUseCase(messageScope: MessageScope): RemoveMessageDraftUseCase = + messageScope.removeMessageDraftUseCase } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 073c303e541..f7b559b38b2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -124,6 +124,7 @@ import com.wire.android.ui.home.conversations.info.ConversationInfoViewModel import com.wire.android.ui.home.conversations.info.ConversationInfoViewState import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState +import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel import com.wire.android.ui.home.conversations.migration.ConversationMigrationViewModel import com.wire.android.ui.home.conversations.model.ExpirationStatus import com.wire.android.ui.home.conversations.model.UIMessage @@ -132,6 +133,7 @@ import com.wire.android.ui.home.conversations.selfdeletion.SelfDeletionMenuItems import com.wire.android.ui.home.gallery.MediaGalleryActionType import com.wire.android.ui.home.gallery.MediaGalleryNavBackArgs import com.wire.android.ui.home.messagecomposer.MessageComposer +import com.wire.android.ui.home.messagecomposer.model.MessageComposition import com.wire.android.ui.home.messagecomposer.state.MessageBundle import com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder import com.wire.android.ui.home.messagecomposer.state.rememberMessageComposerStateHolder @@ -187,6 +189,7 @@ fun ConversationScreen( conversationMessagesViewModel: ConversationMessagesViewModel = hiltViewModel(), messageComposerViewModel: MessageComposerViewModel = hiltViewModel(), conversationMigrationViewModel: ConversationMigrationViewModel = hiltViewModel(), + messageDraftViewModel: MessageDraftViewModel = hiltViewModel(), groupDetailsScreenResultRecipient: ResultRecipient, mediaGalleryScreenResultRecipient: ResultRecipient, resultNavigator: ResultBackNavigator, @@ -199,7 +202,9 @@ fun ConversationScreen( val messageComposerViewState = messageComposerViewModel.messageComposerViewState val messageComposerStateHolder = rememberMessageComposerStateHolder( messageComposerViewState = messageComposerViewState, - modalBottomSheetState = conversationScreenState.modalBottomSheetState + modalBottomSheetState = conversationScreenState.modalBottomSheetState, + messageComposition = messageDraftViewModel.state, + onSaveDraft = messageComposerViewModel::saveDraft ) val permissionPermanentlyDeniedDialogState = rememberVisibilityState() @@ -214,6 +219,17 @@ fun ConversationScreen( } } + // set message composer input to edit mode when editMessage is not null from MessageDraft + LaunchedEffect(messageDraftViewModel.state.value.editMessageId) { + val compositionState = messageDraftViewModel.state.value + if (compositionState.editMessageId != null) { + messageComposerStateHolder.toEdit( + messageId = compositionState.editMessageId, + editMessageText = compositionState.messageText, + mentions = compositionState.selectedMentions.map { it.intoMessageMention() }) + } + } + conversationMigrationViewModel.migratedConversationId?.let { migratedConversationId -> navigator.navigate( NavigationCommand( @@ -1047,10 +1063,13 @@ private fun CoroutineScope.withSmoothScreenLoad(block: () -> Unit) = launch { @Composable fun PreviewConversationScreen() { val messageComposerViewState = remember { mutableStateOf(MessageComposerViewState()) } + val messageCompositionState = remember { mutableStateOf(MessageComposition.DEFAULT) } val conversationScreenState = rememberConversationScreenState() val messageComposerStateHolder = rememberMessageComposerStateHolder( messageComposerViewState = messageComposerViewState, - modalBottomSheetState = conversationScreenState.modalBottomSheetState + modalBottomSheetState = conversationScreenState.modalBottomSheetState, + messageComposition = messageCompositionState, + onSaveDraft = {} ) ConversationScreen( bannerMessage = null, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModel.kt index 895d94e96de..31c19ae6c5f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModel.kt @@ -52,6 +52,7 @@ import com.wire.kalium.logic.data.asset.KaliumFileSystem import com.wire.kalium.logic.data.conversation.Conversation.TypingIndicatorMode import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.message.SelfDeletionTimer +import com.wire.kalium.logic.data.message.draft.MessageDraft import com.wire.kalium.logic.data.user.OtherUser import com.wire.kalium.logic.failure.LegalHoldEnabledForConversationFailure import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase @@ -73,6 +74,8 @@ import com.wire.kalium.logic.feature.message.SendEditTextMessageUseCase import com.wire.kalium.logic.feature.message.SendKnockUseCase import com.wire.kalium.logic.feature.message.SendLocationUseCase import com.wire.kalium.logic.feature.message.SendTextMessageUseCase +import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase +import com.wire.kalium.logic.feature.message.draft.SaveMessageDraftUseCase import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase @@ -120,6 +123,8 @@ class MessageComposerViewModel @Inject constructor( private val setNotifiedAboutConversationUnderLegalHold: SetNotifiedAboutConversationUnderLegalHoldUseCase, private val observeConversationUnderLegalHoldNotified: ObserveConversationUnderLegalHoldNotifiedUseCase, private val sendLocation: SendLocationUseCase, + private val saveMessageDraft: SaveMessageDraftUseCase, + private val removeMessageDraft: RemoveMessageDraftUseCase ) : SavedStateViewModel(savedStateHandle) { var messageComposerViewState = mutableStateOf(MessageComposerViewState()) @@ -240,6 +245,7 @@ class MessageComposerViewModel @Inject constructor( mentions = newMentions.map { it.intoMessageMention() }, ).handleLegalHoldFailureAfterSendingMessage() } + removeMessageDraft(conversationId) sendTypingEvent(conversationId, TypingIndicatorMode.STOPPED) } @@ -265,6 +271,7 @@ class MessageComposerViewModel @Inject constructor( quotedMessageId = quotedMessageId ).handleLegalHoldFailureAfterSendingMessage() } + removeMessageDraft(conversationId) sendTypingEvent(conversationId, TypingIndicatorMode.STOPPED) } @@ -506,6 +513,12 @@ class MessageComposerViewModel @Inject constructor( } } + fun saveDraft(messageDraft: MessageDraft) { + viewModelScope.launch { + saveMessageDraft(conversationId, messageDraft) + } + } + fun acceptSureAboutSendingMessage() { (sureAboutMessagingDialogState as? SureAboutMessagingDialogState.Visible)?.let { viewModelScope.launch { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt new file mode 100644 index 00000000000..f552e5e45ed --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt @@ -0,0 +1,84 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.messages.draft + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.wire.android.navigation.SavedStateViewModel +import com.wire.android.ui.home.conversations.ConversationNavArgs +import com.wire.android.ui.home.conversations.model.UIQuotedMessage +import com.wire.android.ui.home.conversations.model.toUiMention +import com.wire.android.ui.home.conversations.usecase.GetQuoteMessageForConversationUseCase +import com.wire.android.ui.home.messagecomposer.model.MessageComposition +import com.wire.android.ui.home.messagecomposer.model.update +import com.wire.android.ui.navArgs +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.feature.message.draft.GetMessageDraftUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MessageDraftViewModel @Inject constructor( + override val savedStateHandle: SavedStateHandle, + private val getMessageDraft: GetMessageDraftUseCase, + private val getQuotedMessage: GetQuoteMessageForConversationUseCase, +) : SavedStateViewModel(savedStateHandle) { + + private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() + val conversationId: QualifiedID = conversationNavArgs.conversationId + + var state = mutableStateOf(MessageComposition.DEFAULT.copy(messageTextFieldValue = TextFieldValue(""))) + private set + + init { + loadMessageDraft() + } + + private fun loadMessageDraft() { + viewModelScope.launch { + val draftResult = getMessageDraft(conversationId) + + draftResult?.let { draft -> + state.update { messageComposition -> + messageComposition.copy( + messageTextFieldValue = TextFieldValue(draft.text), + selectedMentions = draft.selectedMentionList.mapNotNull { it.toUiMention(draft.text) }, + editMessageId = draft.editMessageId + ) + } + } + draftResult?.quotedMessageId?.let { quotedMessageId -> + when (val quotedData = getQuotedMessage(conversationId, quotedMessageId)) { + is UIQuotedMessage.UIQuotedData -> { + state.update { + it.copy( + quotedMessage = quotedData, + quotedMessageId = quotedMessageId + ) + } + } + + UIQuotedMessage.UnavailableData -> {} + } + } + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMention.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMention.kt new file mode 100644 index 00000000000..9a90902d5ea --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMention.kt @@ -0,0 +1,42 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.model + +import com.wire.kalium.logic.data.message.mention.MessageMention +import com.wire.kalium.logic.data.user.UserId + +data class UIMention( + val start: Int, + val length: Int, + val userId: UserId, + val handler: String // name that should be displayed in a message +) { + fun intoMessageMention() = MessageMention(start, length, userId, false) // We can never send a self mention message +} + +fun MessageMention.toUiMention(originalText: String): UIMention? = + if (start + length <= originalText.length && originalText.elementAt(start) == '@') { + UIMention( + start = start, + length = length, + userId = userId, + handler = originalText.substring(start, start + length) + ) + } else { + null + } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt index ca4b5e2abe5..5d00263e336 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt @@ -580,41 +580,6 @@ data class MessageBody( val quotedMessage: UIQuotedMessage? = null ) -sealed class UIQuotedMessage { - - object UnavailableData : UIQuotedMessage() - - data class UIQuotedData( - val messageId: String, - val senderId: UserId, - val senderName: UIText, - val originalMessageDateDescription: UIText, - val editedTimeDescription: UIText?, - val quotedContent: Content - ) : UIQuotedMessage() { - - sealed interface Content - - data class Text(val value: String) : Content - - data class GenericAsset( - val assetName: String?, - val assetMimeType: String - ) : Content - - data class DisplayableImage( - val displayable: ImageAsset.PrivateAsset - ) : Content - - data class Location(val locationName: String) : Content - - object AudioMessage : Content - - object Deleted : Content - object Invalid : Content - } -} - enum class MessageSource { Self, OtherUser } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIQuotedMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIQuotedMessage.kt new file mode 100644 index 00000000000..a8225875d6b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIQuotedMessage.kt @@ -0,0 +1,92 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.model + +import com.wire.android.appLogger +import com.wire.android.model.ImageAsset +import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.data.user.UserId + +sealed class UIQuotedMessage { + + object UnavailableData : UIQuotedMessage() + + data class UIQuotedData( + val messageId: String, + val senderId: UserId, + val senderName: UIText, + val originalMessageDateDescription: UIText, + val editedTimeDescription: UIText?, + val quotedContent: Content + ) : UIQuotedMessage() { + + sealed interface Content + + data class Text(val value: String) : Content + + data class GenericAsset( + val assetName: String?, + val assetMimeType: String + ) : Content + + data class DisplayableImage( + val displayable: ImageAsset.PrivateAsset + ) : Content + + data class Location(val locationName: String) : Content + + object AudioMessage : Content + + object Deleted : Content + object Invalid : Content + } +} + +fun UIMessage.Regular.mapToQuotedContent(): UIQuotedMessage.UIQuotedData.Content? = + when (val messageContent = messageContent) { + is UIMessageContent.AssetMessage -> UIQuotedMessage.UIQuotedData.GenericAsset( + assetName = messageContent.assetName, + assetMimeType = messageContent.assetExtension + ) + + is UIMessageContent.RestrictedAsset -> UIQuotedMessage.UIQuotedData.GenericAsset( + assetName = messageContent.assetName, + assetMimeType = messageContent.mimeType + ) + + is UIMessageContent.TextMessage -> UIQuotedMessage.UIQuotedData.Text( + value = messageContent.messageBody.message.asString(null) + ) + + is UIMessageContent.AudioAssetMessage -> UIQuotedMessage.UIQuotedData.AudioMessage + + is UIMessageContent.ImageMessage -> messageContent.asset?.let { + UIQuotedMessage.UIQuotedData.DisplayableImage( + displayable = messageContent.asset + ) + } + + is UIMessageContent.Location -> with(messageContent) { + UIQuotedMessage.UIQuotedData.Location(locationName = name) + } + + else -> { + appLogger.w("Attempting to reply to an unsupported message type of content = $messageContent") + null + } + } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetQuoteMessageForConversationUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetQuoteMessageForConversationUseCase.kt new file mode 100644 index 00000000000..cdf35981fef --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetQuoteMessageForConversationUseCase.kt @@ -0,0 +1,72 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.conversations.usecase + +import com.wire.android.mapper.MessageMapper +import com.wire.android.ui.home.conversations.model.UIMessage +import com.wire.android.ui.home.conversations.model.UIQuotedMessage +import com.wire.android.ui.home.conversations.model.mapToQuotedContent +import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.ui.toUIText +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.message.Message +import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class GetQuoteMessageForConversationUseCase @Inject constructor( + private val getMessageById: GetMessageByIdUseCase, + private val getUsersForMessage: GetUsersForMessageUseCase, + private val messageMapper: MessageMapper, + private val dispatchers: DispatcherProvider, +) { + + suspend operator fun invoke(conversationId: ConversationId, quotedMessageId: String): UIQuotedMessage = withContext(dispatchers.io()) { + when (val result = getMessageById(conversationId, quotedMessageId)) { + is GetMessageByIdUseCase.Result.Failure -> UIQuotedMessage.UnavailableData + is GetMessageByIdUseCase.Result.Success -> { + when (val message = result.message) { + is Message.Regular -> { + val usersForMessage = getUsersForMessage(message) + when (val uiMessage = messageMapper.toUIMessage(usersForMessage, message)) { + is UIMessage.Regular -> uiMessage.mapToQuotedContent()?.let { quotedContent -> + uiMessage.header.userId?.let { senderId -> + UIQuotedMessage.UIQuotedData( + messageId = uiMessage.header.messageId, + senderId = senderId, + senderName = uiMessage.header.username, + originalMessageDateDescription = "".toUIText(), + editedTimeDescription = "".toUIText(), + quotedContent = quotedContent + ) + } + } ?: UIQuotedMessage.UnavailableData + + is UIMessage.System -> UIQuotedMessage.UnavailableData + null -> UIQuotedMessage.UnavailableData + } + } + + is Message.Signaling -> UIQuotedMessage.UnavailableData + is Message.System -> UIQuotedMessage.UnavailableData + } + } + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/UiMention.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt similarity index 86% rename from app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/UiMention.kt rename to app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt index d952564dd0e..712fa77038b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/UiMention.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt @@ -30,17 +30,6 @@ import com.wire.android.ui.home.conversations.mention.MemberItemToMention import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.home.newconversation.model.Contact import com.wire.android.ui.theme.wireColorScheme -import com.wire.kalium.logic.data.message.mention.MessageMention -import com.wire.kalium.logic.data.user.UserId - -data class UiMention( - val start: Int, - val length: Int, - val userId: UserId, - val handler: String // name that should be displayed in a message -) { - fun intoMessageMention() = MessageMention(start, length, userId, false) // We can never send a self mention message -} @Composable fun MembersMentionList( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt index 004487da500..488e2ac28ca 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt @@ -52,13 +52,13 @@ import com.wire.android.ui.common.bottomsheet.WireModalSheetState import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.home.conversations.MessageComposerViewState +import com.wire.android.ui.home.messagecomposer.model.MessageComposition import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionStateHolder import com.wire.android.ui.home.messagecomposer.state.ComposableMessageBundle.AttachmentPickedBundle import com.wire.android.ui.home.messagecomposer.state.ComposableMessageBundle.AudioMessageBundle import com.wire.android.ui.home.messagecomposer.state.ComposableMessageBundle.LocationBundle import com.wire.android.ui.home.messagecomposer.state.MessageBundle import com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder -import com.wire.android.ui.home.messagecomposer.state.MessageComposition import com.wire.android.ui.home.messagecomposer.state.MessageCompositionHolder import com.wire.android.ui.home.messagecomposer.state.MessageCompositionInputStateHolder import com.wire.android.ui.home.messagecomposer.state.Ping @@ -259,7 +259,8 @@ private fun BaseComposerPreview( selfDeletionTimer = selfDeletionTimer ), messageCompositionHolder = MessageCompositionHolder( - context = LocalContext.current + messageComposition = messageComposition, + {} ), additionalOptionStateHolder = AdditionalOptionStateHolder(), modalBottomSheetState = WireModalSheetState() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt index e05f159ea58..f4b384bf0e0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt @@ -55,12 +55,13 @@ import androidx.compose.ui.unit.dp import com.wire.android.R import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.spacers.VerticalSpace import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.common.textfield.WireTextFieldColors import com.wire.android.ui.home.conversations.UsersTypingIndicatorForConversation import com.wire.android.ui.home.conversations.messages.QuotedMessagePreview import com.wire.android.ui.home.messagecomposer.attachments.AdditionalOptionButton -import com.wire.android.ui.home.messagecomposer.state.MessageComposition +import com.wire.android.ui.home.messagecomposer.model.MessageComposition import com.wire.android.ui.home.messagecomposer.state.MessageCompositionType import com.wire.android.ui.home.messagecomposer.state.MessageType import com.wire.android.ui.theme.wireColorScheme @@ -101,6 +102,7 @@ fun ActiveMessageComposerInput( } messageComposition.quotedMessage?.let { quotedMessage -> + VerticalSpace.x4() Box(modifier = Modifier.padding(horizontal = dimensions().spacing8x)) { QuotedMessagePreview( quotedMessageData = quotedMessage, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageComposition.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageComposition.kt new file mode 100644 index 00000000000..1c27ec133d3 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageComposition.kt @@ -0,0 +1,158 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.messagecomposer.model + +import androidx.compose.runtime.MutableState +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.wire.android.ui.home.conversations.model.UIMention +import com.wire.android.ui.home.conversations.model.UIQuotedMessage +import com.wire.android.ui.home.messagecomposer.state.ComposableMessageBundle +import com.wire.android.util.EMPTY +import com.wire.android.util.MENTION_SYMBOL +import com.wire.android.util.NEW_LINE_SYMBOL +import com.wire.android.util.WHITE_SPACE +import com.wire.kalium.logic.data.message.draft.MessageDraft + +data class MessageComposition( + val messageTextFieldValue: TextFieldValue = TextFieldValue(""), + val editMessageId: String? = null, + val quotedMessage: UIQuotedMessage.UIQuotedData? = null, + val quotedMessageId: String? = null, + val selectedMentions: List = emptyList(), +) { + companion object { + val DEFAULT = MessageComposition( + messageTextFieldValue = TextFieldValue(text = ""), + quotedMessage = null, + selectedMentions = emptyList() + ) + } + + val messageText: String + get() = messageTextFieldValue.text + + fun mentionSelection(): TextFieldValue { + val beforeSelection = messageTextFieldValue.text + .subSequence(0, messageTextFieldValue.selection.min) + .run { + if (endsWith(String.WHITE_SPACE) || endsWith(String.NEW_LINE_SYMBOL) || this == String.EMPTY) { + this.toString() + } else { + StringBuilder(this) + .append(String.WHITE_SPACE) + .toString() + } + } + + val afterSelection = messageTextFieldValue.text + .subSequence( + messageTextFieldValue.selection.max, + messageTextFieldValue.text.length + ) + + val resultText = StringBuilder(beforeSelection) + .append(String.MENTION_SYMBOL) + .append(afterSelection) + .toString() + + val newSelection = TextRange(beforeSelection.length + 1) + + return TextFieldValue(resultText, newSelection) + } + + fun insertMentionIntoText(mention: UIMention): TextFieldValue { + val beforeMentionText = messageTextFieldValue.text + .subSequence(0, mention.start) + + val afterMentionText = messageTextFieldValue.text + .subSequence( + messageTextFieldValue.selection.max, + messageTextFieldValue.text.length + ) + + val resultText = StringBuilder() + .append(beforeMentionText) + .append(mention.handler) + .apply { + if (!afterMentionText.startsWith(String.WHITE_SPACE)) append(String.WHITE_SPACE) + } + .append(afterMentionText) + .toString() + + // + 1 cause we add space after mention and move selector there + val newSelection = TextRange(beforeMentionText.length + mention.handler.length + 1) + + return TextFieldValue(resultText, newSelection) + } + + fun getSelectedMentions(newMessageText: TextFieldValue): List { + val result = mutableSetOf() + + selectedMentions.forEach { mention -> + if (newMessageText.text.length >= mention.start + mention.length) { + val substringInMentionPlace = newMessageText.text.substring( + mention.start, + mention.start + mention.length + ) + if (substringInMentionPlace == mention.handler) { + result.add(mention) + return@forEach + } + } + + val prevMentionEnd = result.lastOrNull()?.let { it.start + it.length } ?: 0 + val newIndexOfMention = newMessageText.text.indexOf(mention.handler, prevMentionEnd) + if (newIndexOfMention >= 0) { + result.add(mention.copy(start = newIndexOfMention)) + } + } + + return result.toList() + } + + fun toMessageBundle(): ComposableMessageBundle { + return if (editMessageId != null) { + ComposableMessageBundle.EditMessageBundle( + originalMessageId = editMessageId, + newContent = messageTextFieldValue.text, + newMentions = selectedMentions + ) + } else { + ComposableMessageBundle.SendTextMessageBundle( + message = messageTextFieldValue.text, + mentions = selectedMentions, + quotedMessageId = quotedMessageId + ) + } + } +} + +fun MutableState.update(block: (MessageComposition) -> MessageComposition) { + val currentValue = value + value = block(currentValue) +} + +fun MessageComposition.toDraft(): MessageDraft { + return MessageDraft( + text = messageTextFieldValue.text, + editMessageId = editMessageId, + quotedMessageId = quotedMessageId, + selectedMentionList = selectedMentions.map { it.intoMessageMention() } + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt index a4913c6a586..cfcc06c3b52 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt @@ -23,26 +23,29 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import com.wire.android.ui.common.bottomsheet.WireModalSheetState import com.wire.android.ui.home.conversations.MessageComposerViewState import com.wire.android.ui.home.conversations.model.UIMessage +import com.wire.android.ui.home.messagecomposer.model.MessageComposition import com.wire.kalium.logic.data.message.SelfDeletionTimer +import com.wire.kalium.logic.data.message.draft.MessageDraft import com.wire.kalium.logic.data.message.mention.MessageMention @Suppress("LongParameterList") @Composable fun rememberMessageComposerStateHolder( messageComposerViewState: MutableState, - modalBottomSheetState: WireModalSheetState + modalBottomSheetState: WireModalSheetState, + messageComposition: MutableState, + onSaveDraft: (MessageDraft) -> Unit, ): MessageComposerStateHolder { - val context = LocalContext.current val density = LocalDensity.current val messageCompositionHolder = remember { MessageCompositionHolder( - context = context + messageComposition = messageComposition, + onSaveDraft = onSaveDraft ) } @@ -56,13 +59,13 @@ fun rememberMessageComposerStateHolder( val messageCompositionInputStateHolder = rememberSaveable( saver = MessageCompositionInputStateHolder.saver( - messageComposition = messageCompositionHolder.messageComposition, + messageComposition = messageComposition, selfDeletionTimer = selfDeletionTimer, density = density ) ) { MessageCompositionInputStateHolder( - messageComposition = messageCompositionHolder.messageComposition, + messageComposition = messageComposition, selfDeletionTimer = selfDeletionTimer ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt index f92403f7b9a..168c1e86414 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt @@ -17,19 +17,20 @@ */ package com.wire.android.ui.home.messagecomposer.state -import android.content.Context import android.location.Location import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.getSelectedText -import com.wire.android.appLogger +import com.wire.android.ui.home.conversations.model.UIMention import com.wire.android.ui.home.conversations.model.UIMessage -import com.wire.android.ui.home.conversations.model.UIMessageContent import com.wire.android.ui.home.conversations.model.UIQuotedMessage import com.wire.android.ui.home.conversations.model.UriAsset -import com.wire.android.ui.home.messagecomposer.UiMention +import com.wire.android.ui.home.conversations.model.mapToQuotedContent +import com.wire.android.ui.home.conversations.model.toUiMention +import com.wire.android.ui.home.messagecomposer.model.MessageComposition +import com.wire.android.ui.home.messagecomposer.model.toDraft +import com.wire.android.ui.home.messagecomposer.model.update import com.wire.android.ui.home.newconversation.model.Contact import com.wire.android.util.EMPTY import com.wire.android.util.MENTION_SYMBOL @@ -37,6 +38,7 @@ import com.wire.android.util.NEW_LINE_SYMBOL import com.wire.android.util.WHITE_SPACE import com.wire.android.util.ui.toUIText import com.wire.kalium.logic.data.conversation.Conversation.TypingIndicatorMode +import com.wire.kalium.logic.data.message.draft.MessageDraft import com.wire.kalium.logic.data.message.mention.MessageMention import com.wire.kalium.logic.data.user.UserId @@ -45,17 +47,17 @@ import com.wire.kalium.logic.data.user.UserId * A single entry point to update the state of the message. */ class MessageCompositionHolder( - private val context: Context + val messageComposition: MutableState, + private val onSaveDraft: (MessageDraft) -> Unit, ) { private companion object { const val RICH_TEXT_MARKDOWN_MULTIPLIER = 2 } - val messageComposition: MutableState = mutableStateOf(MessageComposition.DEFAULT) fun setReply(message: UIMessage.Regular) { val senderId = message.header.userId ?: return - mapToQuotedContent(message)?.let { quotedContent -> + message.mapToQuotedContent()?.let { quotedContent -> val quotedMessage = UIQuotedMessage.UIQuotedData( messageId = message.header.messageId, senderId = senderId, @@ -72,42 +74,9 @@ class MessageCompositionHolder( ) } } + onSaveDraft(messageComposition.value.toDraft()) } - private fun mapToQuotedContent(message: UIMessage.Regular) = - when (val messageContent = message.messageContent) { - is UIMessageContent.AssetMessage -> UIQuotedMessage.UIQuotedData.GenericAsset( - assetName = messageContent.assetName, - assetMimeType = messageContent.assetExtension - ) - - is UIMessageContent.RestrictedAsset -> UIQuotedMessage.UIQuotedData.GenericAsset( - assetName = messageContent.assetName, - assetMimeType = messageContent.mimeType - ) - - is UIMessageContent.TextMessage -> UIQuotedMessage.UIQuotedData.Text( - value = messageContent.messageBody.message.asString(context.resources) - ) - - is UIMessageContent.AudioAssetMessage -> UIQuotedMessage.UIQuotedData.AudioMessage - - is UIMessageContent.ImageMessage -> messageContent.asset?.let { - UIQuotedMessage.UIQuotedData.DisplayableImage( - displayable = messageContent.asset - ) - } - - is UIMessageContent.Location -> with(messageContent) { - UIQuotedMessage.UIQuotedData.Location(locationName = name) - } - - else -> { - appLogger.w("Attempting to reply to an unsupported message type of content = $messageContent") - null - } - } - fun clearReply() { messageComposition.update { it.copy( @@ -115,6 +84,7 @@ class MessageCompositionHolder( quotedMessageId = null ) } + onSaveDraft(messageComposition.value.toDraft()) } fun setMessageText( @@ -134,6 +104,7 @@ class MessageCompositionHolder( messageComposition.update { it.copy(messageTextFieldValue = messageTextFieldValue) } + onSaveDraft(messageComposition.value.toDraft()) } private fun updateTypingEvent(messageTextFieldValue: TextFieldValue, onTypingEvent: (TypingIndicatorMode) -> Unit) { @@ -191,15 +162,20 @@ class MessageCompositionHolder( } fun addMention(contact: Contact) { - val mention = UiMention( + val mention = UIMention( start = messageComposition.value.messageTextFieldValue.currentMentionStartIndex(), length = contact.name.length + 1, // +1 cause there is an "@" before it userId = UserId(contact.id, contact.domain), handler = String.MENTION_SYMBOL + contact.name ) - messageComposition.update { it.copy(messageTextFieldValue = it.insertMentionIntoText(mention)) } - messageComposition.update { it.copy(selectedMentions = it.selectedMentions.plus(mention).sortedBy { it.start }) } + messageComposition.update { + it.copy( + messageTextFieldValue = it.insertMentionIntoText(mention), + selectedMentions = it.selectedMentions.plus(mention).sortedBy { it.start } + ) + } + onSaveDraft(messageComposition.value.toDraft()) } fun setEditText(messageId: String, editMessageText: String, mentions: List) { @@ -208,11 +184,12 @@ class MessageCompositionHolder( messageTextFieldValue = (TextFieldValue( text = editMessageText, selection = TextRange(editMessageText.length) - )) + )), + selectedMentions = mentions.mapNotNull { it.toUiMention(editMessageText) }, + editMessageId = messageId ) } - messageComposition.update { it.copy(selectedMentions = mentions.map { it.toUiMention(editMessageText) }) } - messageComposition.update { it.copy(editMessageId = messageId) } + onSaveDraft(messageComposition.value.toDraft()) } fun addOrRemoveMessageMarkdown( @@ -267,6 +244,7 @@ class MessageCompositionHolder( ) ) } + onSaveDraft(messageComposition.value.toDraft()) } fun clearMessage() { @@ -278,137 +256,12 @@ class MessageCompositionHolder( editMessageId = null ) } + onSaveDraft(messageComposition.value.toDraft()) } fun toMessageBundle() = messageComposition.value.toMessageBundle() } -private fun MessageMention.toUiMention(originalText: String) = UiMention( - start = start, - length = length, - userId = userId, - handler = originalText.substring(start, start + length) -) - -data class MessageComposition( - val messageTextFieldValue: TextFieldValue = TextFieldValue(""), - val editMessageId: String? = null, - val quotedMessage: UIQuotedMessage.UIQuotedData? = null, - val quotedMessageId: String? = null, - val selectedMentions: List = emptyList(), -) { - companion object { - val DEFAULT = MessageComposition( - messageTextFieldValue = TextFieldValue(text = ""), - quotedMessage = null, - selectedMentions = emptyList() - ) - } - - val messageText: String - get() = messageTextFieldValue.text - - fun mentionSelection(): TextFieldValue { - val beforeSelection = messageTextFieldValue.text - .subSequence(0, messageTextFieldValue.selection.min) - .run { - if (endsWith(String.WHITE_SPACE) || endsWith(String.NEW_LINE_SYMBOL) || this == String.EMPTY) { - this.toString() - } else { - StringBuilder(this) - .append(String.WHITE_SPACE) - .toString() - } - } - - val afterSelection = messageTextFieldValue.text - .subSequence( - messageTextFieldValue.selection.max, - messageTextFieldValue.text.length - ) - - val resultText = StringBuilder(beforeSelection) - .append(String.MENTION_SYMBOL) - .append(afterSelection) - .toString() - - val newSelection = TextRange(beforeSelection.length + 1) - - return TextFieldValue(resultText, newSelection) - } - - fun insertMentionIntoText(mention: UiMention): TextFieldValue { - val beforeMentionText = messageTextFieldValue.text - .subSequence(0, mention.start) - - val afterMentionText = messageTextFieldValue.text - .subSequence( - messageTextFieldValue.selection.max, - messageTextFieldValue.text.length - ) - - val resultText = StringBuilder() - .append(beforeMentionText) - .append(mention.handler) - .apply { - if (!afterMentionText.startsWith(String.WHITE_SPACE)) append(String.WHITE_SPACE) - } - .append(afterMentionText) - .toString() - - // + 1 cause we add space after mention and move selector there - val newSelection = TextRange(beforeMentionText.length + mention.handler.length + 1) - - return TextFieldValue(resultText, newSelection) - } - - fun getSelectedMentions(newMessageText: TextFieldValue): List { - val result = mutableSetOf() - - selectedMentions.forEach { mention -> - if (newMessageText.text.length >= mention.start + mention.length) { - val substringInMentionPlace = newMessageText.text.substring( - mention.start, - mention.start + mention.length - ) - if (substringInMentionPlace == mention.handler) { - result.add(mention) - return@forEach - } - } - - val prevMentionEnd = result.lastOrNull()?.let { it.start + it.length } ?: 0 - val newIndexOfMention = newMessageText.text.indexOf(mention.handler, prevMentionEnd) - if (newIndexOfMention >= 0) { - result.add(mention.copy(start = newIndexOfMention)) - } - } - - return result.toList() - } - - fun toMessageBundle(): ComposableMessageBundle { - return if (editMessageId != null) { - ComposableMessageBundle.EditMessageBundle( - originalMessageId = editMessageId, - newContent = messageTextFieldValue.text, - newMentions = selectedMentions - ) - } else { - ComposableMessageBundle.SendTextMessageBundle( - message = messageTextFieldValue.text, - mentions = selectedMentions, - quotedMessageId = quotedMessageId - ) - } - } -} - -fun MutableState.update(block: (MessageComposition) -> MessageComposition) { - val currentValue = value - value = block(currentValue) -} - private fun TextFieldValue.currentMentionStartIndex(): Int { val lastIndexOfAt = text.lastIndexOf(String.MENTION_SYMBOL, selection.min - 1) @@ -430,12 +283,12 @@ sealed class ComposableMessageBundle : MessageBundle { data class EditMessageBundle( val originalMessageId: String, val newContent: String, - val newMentions: List + val newMentions: List ) : ComposableMessageBundle() data class SendTextMessageBundle( val message: String, - val mentions: List, + val mentions: List, val quotedMessageId: String? = null ) : ComposableMessageBundle() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt index 6b7fc7d6e66..c5b9ccef364 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt @@ -37,6 +37,7 @@ import com.wire.android.R import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.textfield.WireTextFieldColors import com.wire.android.ui.common.textfield.wireTextFieldColors +import com.wire.android.ui.home.messagecomposer.model.MessageComposition import com.wire.android.util.ui.KeyboardHeight import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.util.isPositiveNotNull diff --git a/app/src/main/kotlin/com/wire/android/util/ui/UIText.kt b/app/src/main/kotlin/com/wire/android/util/ui/UIText.kt index 617cb09e88f..af3fd64d2ba 100644 --- a/app/src/main/kotlin/com/wire/android/util/ui/UIText.kt +++ b/app/src/main/kotlin/com/wire/android/util/ui/UIText.kt @@ -24,6 +24,7 @@ import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import com.wire.android.appLogger import com.wire.kalium.logic.data.message.mention.MessageMention sealed class UIText { @@ -45,17 +46,32 @@ sealed class UIText { @Suppress("SpreadOperator") @Composable - fun asString() = when (this) { + fun asString(): String = when (this) { is DynamicString -> value is StringResource -> stringResource(id = resId, *formatArgs) is PluralResource -> pluralStringResource(id = resId, count, *formatArgs) } @Suppress("SpreadOperator") - fun asString(resources: Resources) = when (this) { + fun asString(resources: Resources?): String = when (this) { is DynamicString -> value - is StringResource -> resources.getString(resId, *formatArgs) - is PluralResource -> resources.getQuantityString(resId, count, *formatArgs) + is StringResource -> resources?.getString(resId, *formatArgs).let { + if (it != null) { + it + } else { + appLogger.w("StringResource used with nullable Resources") + "" + } + } + + is PluralResource -> resources?.getQuantityString(resId, count, *formatArgs).let { + if (it != null) { + it + } else { + appLogger.w("PluralResource used with nullable Resources") + "" + } + } } override fun equals(other: Any?): Boolean { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelArrangement.kt index 27e0e2cbde5..16985d723fc 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelArrangement.kt @@ -74,6 +74,8 @@ import com.wire.kalium.logic.feature.message.SendEditTextMessageUseCase import com.wire.kalium.logic.feature.message.SendKnockUseCase import com.wire.kalium.logic.feature.message.SendLocationUseCase import com.wire.kalium.logic.feature.message.SendTextMessageUseCase +import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase +import com.wire.kalium.logic.feature.message.draft.SaveMessageDraftUseCase import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase @@ -199,6 +201,12 @@ internal class MessageComposerViewModelArrangement { @MockK lateinit var observeConversationUnderLegalHoldNotified: ObserveConversationUnderLegalHoldNotifiedUseCase + @MockK + lateinit var saveMessageDraftUseCase: SaveMessageDraftUseCase + + @MockK + lateinit var removeMessageDraftUseCase: RemoveMessageDraftUseCase + @MockK lateinit var sendLocation: SendLocationUseCase @@ -232,7 +240,9 @@ internal class MessageComposerViewModelArrangement { observeDegradedConversationNotified = observeDegradedConversationNotifiedUseCase, setNotifiedAboutConversationUnderLegalHold = setNotifiedAboutConversationUnderLegalHold, observeConversationUnderLegalHoldNotified = observeConversationUnderLegalHoldNotified, - sendLocation = sendLocation + sendLocation = sendLocation, + saveMessageDraft = saveMessageDraftUseCase, + removeMessageDraft = removeMessageDraftUseCase ) } @@ -363,6 +373,10 @@ internal class MessageComposerViewModelArrangement { coEvery { retryFailedMessageUseCase(any(), any()) } returns Either.Right(Unit) } + fun withSaveDraftMessage() = apply { + coEvery { saveMessageDraftUseCase(any(), any()) } returns Unit + } + fun arrange() = this to viewModel } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelTest.kt index 2077a39c3df..520a56d309c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelTest.kt @@ -32,6 +32,7 @@ import com.wire.android.ui.home.messagecomposer.state.Ping import com.wire.kalium.logic.data.asset.AttachmentType import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.message.SelfDeletionTimer +import com.wire.kalium.logic.data.message.draft.MessageDraft import com.wire.kalium.logic.failure.LegalHoldEnabledForConversationFailure import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCaseImpl.Companion.ASSET_SIZE_DEFAULT_LIMIT_BYTES import io.mockk.coVerify @@ -577,7 +578,7 @@ class MessageComposerViewModelTest { } @Test - fun `given that user sends a text message, when invoked, then send typing stopped event is called`() = runTest { + fun `given that user sends a text message, when invoked, then send typing stopped event and remove draft are called`() = runTest { // given val (arrangement, viewModel) = MessageComposerViewModelArrangement() .withSuccessfulViewModelInit() @@ -602,36 +603,49 @@ class MessageComposerViewModelTest { eq(Conversation.TypingIndicatorMode.STOPPED) ) } + coVerify(exactly = 1) { + arrangement.removeMessageDraftUseCase.invoke(any()) + } } @Test - fun `given that user sends an edited text message, when invoked, then send typing stopped event is called`() = runTest { - // given - val (arrangement, viewModel) = MessageComposerViewModelArrangement() - .withSuccessfulViewModelInit() - .withSuccessfulSendEditTextMessage() - .arrange() - - // when - viewModel.trySendMessage(ComposableMessageBundle.EditMessageBundle("mocked-text-message", "new-mocked-text-message", emptyList())) + fun `given that user sends an edited text message, when invoked, then send typing stopped event and remove draft are called`() = + runTest { + // given + val (arrangement, viewModel) = MessageComposerViewModelArrangement() + .withSuccessfulViewModelInit() + .withSuccessfulSendEditTextMessage() + .arrange() - // then - coVerify(exactly = 1) { - arrangement.sendEditTextMessage.invoke( - any(), - any(), - any(), - any(), - any() - ) - } - coVerify(exactly = 1) { - arrangement.sendTypingEvent.invoke( - any(), - eq(Conversation.TypingIndicatorMode.STOPPED) + // when + viewModel.trySendMessage( + ComposableMessageBundle.EditMessageBundle( + "mocked-text-message", + "new-mocked-text-message", + emptyList() + ) ) + + // then + coVerify(exactly = 1) { + arrangement.sendEditTextMessage.invoke( + any(), + any(), + any(), + any(), + any() + ) + } + coVerify(exactly = 1) { + arrangement.sendTypingEvent.invoke( + any(), + eq(Conversation.TypingIndicatorMode.STOPPED) + ) + } + coVerify(exactly = 1) { + arrangement.removeMessageDraftUseCase.invoke(any()) + } } - } @Test fun `given that user types a text message, when invoked typing invoked, then send typing event is called`() = runTest { @@ -818,4 +832,27 @@ class MessageComposerViewModelTest { coVerify(exactly = 1) { arrangement.sendLocation.invoke(any(), any(), any(), any(), any()) } assertEquals(SureAboutMessagingDialogState.Hidden, viewModel.sureAboutMessagingDialogState) } + + @Test + fun `given that user saves a draft message, then save draft use case is triggered`() = + runTest { + // given + val messageDraft = MessageDraft( + text = "hello", + editMessageId = null, + quotedMessageId = null, + selectedMentionList = listOf() + ) + val (arrangement, viewModel) = MessageComposerViewModelArrangement() + .withSuccessfulViewModelInit() + .withSaveDraftMessage() + .arrange() + + // when + viewModel.saveDraft(messageDraft) + advanceUntilIdle() + + // then + coVerify(exactly = 1) { arrangement.saveMessageDraftUseCase.invoke(eq(viewModel.conversationId), eq(messageDraft)) } + } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModelTest.kt new file mode 100644 index 00000000000..85908c553bc --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModelTest.kt @@ -0,0 +1,199 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.messages.draft + +import androidx.lifecycle.SavedStateHandle +import com.wire.android.R +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.NavigationTestExtension +import com.wire.android.config.mockUri +import com.wire.android.ui.home.conversations.ConversationNavArgs +import com.wire.android.ui.home.conversations.model.UIQuotedMessage +import com.wire.android.ui.home.conversations.usecase.GetQuoteMessageForConversationUseCase +import com.wire.android.ui.navArgs +import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.message.draft.MessageDraft +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.message.draft.GetMessageDraftUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class) +@ExtendWith(NavigationTestExtension::class) +class MessageDraftViewModelTest { + + @Test + fun `given message draft, when init, then state is properly updated`() = runTest { + // given + val messageDraft = MessageDraft( + text = "hello", + editMessageId = null, + quotedMessageId = null, + selectedMentionList = listOf() + ) + val (arrangement, viewModel) = Arrangement() + .withMessageDraft(messageDraft) + .arrange() + + // when + advanceUntilIdle() + + // then + assertEquals(messageDraft.text, viewModel.state.value.messageText) + coVerify(exactly = 1) { + arrangement.getMessageDraft(any()) + } + } + + @Test + fun `given null message draft, when init, then state is not updated`() = runTest { + // given + val (arrangement, viewModel) = Arrangement() + .withMessageDraft(null) + .arrange() + + // when + advanceUntilIdle() + + // then + assertTrue(viewModel.state.value.messageText.isEmpty()) + coVerify(exactly = 1) { + arrangement.getMessageDraft(any()) + } + } + + @Test + fun `given message draft with quoted message, when init, then state is updated`() = runTest { + // given + val messageDraft = MessageDraft( + text = "hello", + editMessageId = null, + quotedMessageId = "quoted_message_id", + selectedMentionList = listOf() + ) + val quotedData = UIQuotedMessage.UIQuotedData( + messageId = "quoted_message_id", + senderId = UserId("user_id", "domain"), + senderName = UIText.DynamicString("John"), + originalMessageDateDescription = UIText.StringResource(R.string.label_quote_original_message_date, "10:30"), + editedTimeDescription = UIText.StringResource(R.string.label_message_status_edited_with_date, "10:32"), + quotedContent = UIQuotedMessage.UIQuotedData.Text("Any ideas?") + ) + val (arrangement, viewModel) = Arrangement() + .withMessageDraft(messageDraft) + .withQuotedMessage(quotedData) + .arrange() + + // when + advanceUntilIdle() + + // then + assertEquals(messageDraft.text, viewModel.state.value.messageText) + assertEquals(messageDraft.quotedMessageId, viewModel.state.value.quotedMessageId) + assertEquals(quotedData, viewModel.state.value.quotedMessage) + + coVerify(exactly = 1) { + arrangement.getMessageDraft(any()) + } + coVerify(exactly = 1) { + arrangement.getQuoteMessageForConversation(any(), any()) + } + } + + @Test + fun `given message draft with unavailable quoted message, when init, then quoted data is not updated`() = runTest { + // given + val messageDraft = MessageDraft( + text = "hello", + editMessageId = null, + quotedMessageId = "quoted_message_id", + selectedMentionList = listOf() + ) + val quotedData = UIQuotedMessage.UnavailableData + + val (arrangement, viewModel) = Arrangement() + .withMessageDraft(messageDraft) + .withQuotedMessage(quotedData) + .arrange() + + // when + advanceUntilIdle() + + // then + assertEquals(messageDraft.text, viewModel.state.value.messageText) + assertEquals(null, viewModel.state.value.quotedMessageId) + + coVerify(exactly = 1) { + arrangement.getMessageDraft(any()) + } + coVerify(exactly = 1) { + arrangement.getQuoteMessageForConversation(any(), any()) + } + } + + private class Arrangement { + + val conversationId: ConversationId = ConversationId("some-dummy-value", "some.dummy.domain") + + init { + // Tests setup + MockKAnnotations.init(this, relaxUnitFun = true) + mockUri() + every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId = conversationId) + } + + @MockK + private lateinit var savedStateHandle: SavedStateHandle + + @MockK + lateinit var getMessageDraft: GetMessageDraftUseCase + + @MockK + lateinit var getQuoteMessageForConversation: GetQuoteMessageForConversationUseCase + + private val viewModel by lazy { + MessageDraftViewModel( + savedStateHandle, + getMessageDraft, + getQuoteMessageForConversation + ) + } + + fun withMessageDraft(messageDraft: MessageDraft?) = apply { + coEvery { getMessageDraft(any()) } returns messageDraft + } + + fun withQuotedMessage(quotedMessage: UIQuotedMessage) = apply { + coEvery { getQuoteMessageForConversation(any(), any()) } returns quotedMessage + } + + fun arrange() = this to viewModel + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt index f18c27c7990..d2b0478014b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt @@ -26,11 +26,11 @@ import com.wire.android.config.CoroutineTestExtension import com.wire.android.ui.common.bottomsheet.WireModalSheetState import com.wire.android.ui.home.conversations.MessageComposerViewState import com.wire.android.ui.home.conversations.mock.mockMessageWithText +import com.wire.android.ui.home.messagecomposer.model.MessageComposition import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSelectItem import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionStateHolder import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSubMenuState import com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder -import com.wire.android.ui.home.messagecomposer.state.MessageComposition import com.wire.android.ui.home.messagecomposer.state.MessageCompositionHolder import com.wire.android.ui.home.messagecomposer.state.MessageCompositionInputStateHolder import com.wire.android.ui.home.messagecomposer.state.MessageCompositionType @@ -75,7 +75,8 @@ class MessageComposerStateHolderTest { selfDeletionTimer = mutableStateOf(SelfDeletionTimer.Disabled) ) messageCompositionHolder = MessageCompositionHolder( - context = context + messageComposition = messageComposition, + {} ) additionalOptionStateHolder = AdditionalOptionStateHolder() modalBottomSheetState = WireModalSheetState() diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt index 1314cdec366..d803abbedc5 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt @@ -18,8 +18,12 @@ package com.wire.android.ui.home.messagecomposer.state import android.content.Context +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import com.wire.android.ui.home.messagecomposer.model.MessageComposition +import com.wire.android.ui.home.messagecomposer.model.update import io.mockk.MockKAnnotations import io.mockk.impl.annotations.MockK import kotlinx.coroutines.test.runTest @@ -34,11 +38,14 @@ class MessageCompositionHolderTest { private lateinit var state: MessageCompositionHolder + private lateinit var messageComposition: MutableState + @BeforeEach fun before() { MockKAnnotations.init(this, relaxUnitFun = true) - state = MessageCompositionHolder(context = context) + messageComposition = mutableStateOf(MessageComposition()) + state = MessageCompositionHolder(messageComposition = messageComposition, {}) } @Test diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt index 5febb3ae68c..a6250ab1a35 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.unit.dp import com.wire.android.config.CoroutineTestExtension +import com.wire.android.ui.home.messagecomposer.model.MessageComposition import com.wire.kalium.logic.data.message.SelfDeletionTimer import kotlinx.coroutines.ExperimentalCoroutinesApi import org.amshove.kluent.shouldBeEqualTo