diff --git a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt index 5e29cda3284..dc5afd9738d 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt @@ -27,6 +27,7 @@ import com.wire.android.ui.home.conversations.model.MessageEditStatus import com.wire.android.ui.home.conversations.model.MessageFlowStatus import com.wire.android.ui.home.conversations.model.MessageFooter import com.wire.android.ui.home.conversations.model.MessageHeader +import com.wire.android.ui.home.conversations.model.MessageSenderId import com.wire.android.ui.home.conversations.model.MessageSource import com.wire.android.ui.home.conversations.model.MessageStatus import com.wire.android.ui.home.conversations.model.MessageTime @@ -46,6 +47,7 @@ import com.wire.kalium.logic.data.user.SelfUser import com.wire.kalium.logic.data.user.User import com.wire.kalium.logic.data.user.UserAvailabilityStatus import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.data.user.type.UserTypeInfo import javax.inject.Inject class MessageMapper @Inject constructor( @@ -165,7 +167,14 @@ class MessageMapper @Inject constructor( }, clientId = (message as? Message.Sendable)?.senderClientId, accent = Accent.fromAccentId(sender?.accentId), - guestExpiresAt = sender?.expiresAt + guestExpiresAt = sender?.expiresAt, + senderId = when { + (sender as? OtherUser)?.botService != null -> MessageSenderId.Bot(sender.botService!!) + sender?.userType == UserTypeInfo.App -> MessageSenderId.App(sender.id) + else -> sender?.id?.let { + MessageSenderId.User(it.toString()) + } + } ) private fun getMessageStatus(message: Message.Standalone): MessageStatus { 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 e0ab80f0f4f..3233c4e48af 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 @@ -92,6 +92,7 @@ import com.ramcosta.composedestinations.generated.app.destinations.MediaGalleryS import com.ramcosta.composedestinations.generated.app.destinations.MessageDetailsScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.SelfUserProfileScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.ServiceDetailsScreenDestination import com.ramcosta.composedestinations.generated.sketch.destinations.DrawingCanvasScreenDestination import com.ramcosta.composedestinations.result.NavResult.Canceled import com.ramcosta.composedestinations.result.NavResult.Value @@ -171,6 +172,7 @@ import com.wire.android.ui.home.conversations.messages.item.MessageContainerItem import com.wire.android.ui.home.conversations.messages.item.SwipeableMessageConfiguration 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.MessageSenderId 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 @@ -191,6 +193,7 @@ import com.wire.android.ui.legalhold.dialog.subject.LegalHoldSubjectMessageDialo import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography +import com.wire.android.ui.userprofile.service.ServiceDetailsNavArgs import com.wire.android.util.DateAndTimeParsers import com.wire.android.util.normalizeLink import com.wire.android.util.openDownloadFolder @@ -206,6 +209,7 @@ import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.MessageAssetStatus import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.data.user.type.UserTypeInfo import com.wire.kalium.logic.data.user.type.isInternal import com.wire.kalium.logic.data.user.type.isTeamAdmin import com.wire.kalium.logic.feature.call.usecase.ConferenceCallingResult @@ -497,16 +501,38 @@ fun ConversationScreen( conversationInfoViewState = conversationInfoViewModel.conversationInfoViewState, conversationMessagesViewState = conversationMessagesViewModel.conversationViewState, attachments = messageAttachmentsViewModel.attachments, - onOpenProfile = { + onOpenProfile = { senderId: MessageSenderId -> with(conversationInfoViewModel) { - val (mentionUserId: UserId, isSelfUser: Boolean) = mentionedUserData(it) - if (isSelfUser) { - navigator.navigate(NavigationCommand(SelfUserProfileScreenDestination)) - } else { - (conversationInfoViewState.conversationDetailsData as? ConversationDetailsData.Group)?.conversationId.let { - navigator.navigate(NavigationCommand(OtherUserProfileScreenDestination(mentionUserId, it))) + val route = when (senderId) { + is MessageSenderId.Bot -> ServiceDetailsScreenDestination( + null, + ServiceDetailsNavArgs.Id.BotServiceId(senderId.botService) + ) + + is MessageSenderId.App -> ServiceDetailsScreenDestination( + null, + ServiceDetailsNavArgs.Id.AppId(senderId.appId) + ) + + is MessageSenderId.User -> { + val (mentionUserId: UserId, isSelfUser: Boolean) = mentionedUserData(senderId.id.toString()) + if (isSelfUser) { + SelfUserProfileScreenDestination + } else { + (conversationInfoViewState.conversationDetailsData as? ConversationDetailsData.Group) + ?.conversationId?.let { conversationId -> + OtherUserProfileScreenDestination( + mentionUserId, + conversationId + ) + } + } } } + + route?.let { + navigator.navigate(NavigationCommand(it)) + } } }, onMessageDetailsClick = { messageId: String, isSelfMessage: Boolean -> @@ -601,17 +627,38 @@ fun ConversationScreen( onUpdateConversationReadDate = messageComposerViewModel::updateConversationReadDate, onDropDownClick = { with(conversationInfoViewModel) { - when (val data = conversationInfoViewState.conversationDetailsData) { - is ConversationDetailsData.OneOne -> - navigator.navigate(NavigationCommand(OtherUserProfileScreenDestination(data.otherUserId))) + val route = when (val data = conversationInfoViewState.conversationDetailsData) { + is ConversationDetailsData.OneOne -> { + val botService = data.botService + when { + botService != null -> + ServiceDetailsScreenDestination( + null, + ServiceDetailsNavArgs.Id.BotServiceId(botService) + ) + + data.userType == UserTypeInfo.App -> + ServiceDetailsScreenDestination( + null, + ServiceDetailsNavArgs.Id.AppId(data.otherUserId) + ) + + else -> OtherUserProfileScreenDestination(data.otherUserId) + } + } is ConversationDetailsData.Group -> - navigator.navigate(NavigationCommand(GroupConversationDetailsScreenDestination(conversationId))) + GroupConversationDetailsScreenDestination(conversationId) is ConversationDetailsData.None -> { /* do nothing */ + null } } + + route?.let { + navigator.navigate(NavigationCommand(it)) + } } }, onBackButtonClick = { @@ -915,7 +962,7 @@ private fun ConversationScreen( conversationMessagesViewState: ConversationMessagesViewState, attachments: List, bottomSheetVisible: Boolean, - onOpenProfile: (String) -> Unit, + onOpenProfile: (senderId: MessageSenderId) -> Unit, onMessageDetailsClick: (messageId: String, isSelfMessage: Boolean) -> Unit, onSendMessage: (MessageBundle) -> Unit, onPingOptionClicked: () -> Unit, @@ -1123,7 +1170,7 @@ private fun ConversationScreenContent( onImageFullScreenMode: (UIMessage.Regular, Boolean, String?) -> Unit, onReactionClicked: (String, String) -> Unit, onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit, - onOpenProfile: (String) -> Unit, + onOpenProfile: (senderId: MessageSenderId) -> Unit, onUpdateConversationReadDate: (String) -> Unit, onShowEditingOptions: (UIMessage.Regular) -> Unit, onSwipedToReply: (UIMessage.Regular) -> Unit, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt index 43f49220cdd..4479d86a6fd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt @@ -149,6 +149,8 @@ class ConversationInfoViewModel @Inject constructor( connectionState = conversationDetails.otherUser.connectionStatus, isBlocked = conversationDetails.otherUser.connectionStatus == ConnectionState.BLOCKED, isDeleted = conversationDetails.otherUser.deleted, + botService = conversationDetails.otherUser.botService, + userType = conversationDetails.otherUser.userType ) else -> ConversationDetailsData.None(conversationDetails.conversation.protocol) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewState.kt index fc5182053ff..1c67671a567 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewState.kt @@ -22,9 +22,11 @@ import com.wire.android.model.ImageAsset import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.user.BotService import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.UserAvailabilityStatus import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.data.user.type.UserTypeInfo data class ConversationInfoViewState( val conversationId: QualifiedID, @@ -52,7 +54,9 @@ sealed class ConversationDetailsData(open val conversationProtocol: Conversation val otherUserName: String?, val connectionState: ConnectionState, val isBlocked: Boolean, - val isDeleted: Boolean + val isDeleted: Boolean, + val botService: BotService?, + val userType: UserTypeInfo ) : ConversationDetailsData(conversationProtocol) data class Group( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt index 8d749621874..cdd8dfd73db 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt @@ -17,6 +17,7 @@ */ package com.wire.android.ui.home.conversations.messages.item +import com.wire.android.ui.home.conversations.model.MessageSenderId import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId @@ -24,7 +25,7 @@ import com.wire.kalium.logic.data.user.UserId sealed class MessageClickActions { open val onFullMessageClicked: ((messageId: String) -> Unit)? = null open val onFullMessageLongClicked: ((UIMessage.Regular) -> Unit)? = null - open val onProfileClicked: (String) -> Unit = {} + open val onProfileClicked: (senderId: MessageSenderId) -> Unit = {} open val onReactionClicked: (String, String) -> Unit = { _, _ -> } open val onAssetClicked: (String) -> Unit = {} open val onImageClicked: (UIMessage.Regular, Boolean, String?) -> Unit = { _, _, _ -> } @@ -41,7 +42,7 @@ sealed class MessageClickActions { data class Content( override val onFullMessageLongClicked: ((UIMessage.Regular) -> Unit)? = null, - override val onProfileClicked: (String) -> Unit = {}, + override val onProfileClicked: (senderId: MessageSenderId) -> Unit = {}, override val onReactionClicked: (String, String) -> Unit = { _, _ -> }, override val onAssetClicked: (String) -> Unit = {}, override val onImageClicked: (UIMessage.Regular, Boolean, String?) -> Unit = { _, _, _ -> }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index ed32c1eb861..59cd7b68bf0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -45,6 +45,7 @@ import com.wire.android.ui.home.conversations.messages.QuotedUnavailable import com.wire.android.ui.home.conversations.model.DeliveryStatusContent import com.wire.android.ui.home.conversations.model.MessageBody import com.wire.android.ui.home.conversations.model.MessageImage +import com.wire.android.ui.home.conversations.model.MessageSenderId import com.wire.android.ui.home.conversations.model.MessageSource import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.home.conversations.model.UIMessageContent @@ -70,7 +71,7 @@ internal fun UIMessage.Regular.MessageContentAndStatus( messageStyle: MessageStyle, onAssetClicked: (String) -> Unit, onImageClicked: (UIMessage.Regular, Boolean, String?) -> Unit, - onProfileClicked: (String) -> Unit, + onProfileClicked: (senderId: MessageSenderId) -> Unit, onLinkClicked: (String) -> Unit, onReplyClicked: (UIMessage.Regular) -> Unit, shouldDisplayMessageStatus: Boolean, @@ -163,7 +164,7 @@ private fun MessageContent( onAssetClick: Clickable, onImageClick: Clickable, onMultipartImageClick: (String) -> Unit, - onOpenProfile: (String) -> Unit, + onOpenProfile: (senderId: MessageSenderId) -> Unit, onLinkClick: (String) -> Unit, onReplyClick: Clickable, accent: Accent, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItemLeading.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItemLeading.kt index 8fa2d6bde91..c878849a1bc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItemLeading.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItemLeading.kt @@ -26,6 +26,7 @@ import com.wire.android.model.UserAvatarData import com.wire.android.ui.common.avatar.UserProfileAvatar import com.wire.android.ui.common.avatar.UserProfileAvatarType.WithIndicators import com.wire.android.ui.home.conversations.model.MessageHeader +import com.wire.android.ui.home.conversations.model.MessageSenderId import com.wire.android.ui.common.R as commonR @Composable @@ -33,10 +34,10 @@ fun RegularMessageItemLeading( header: MessageHeader, showAuthor: Boolean, userAvatarData: UserAvatarData, - onOpenProfile: (String) -> Unit + onOpenProfile: (MessageSenderId) -> Unit ) { val isProfileRedirectEnabled = - header.userId != null && !(header.isSenderDeleted || header.isSenderUnavailable) + header.senderId != null && !(header.isSenderDeleted || header.isSenderUnavailable) if (showAuthor) { val openProfileDescription = stringResource(id = R.string.content_description_open_user_profile_label) val avatarClickable = remember(isProfileRedirectEnabled, header.userId, openProfileDescription, onOpenProfile) { @@ -44,7 +45,9 @@ fun RegularMessageItemLeading( enabled = isProfileRedirectEnabled, onClickDescription = openProfileDescription ) { - onOpenProfile(header.userId!!.toString()) + header.senderId?.let { + onOpenProfile(it) + } } } val avatarContentDescription = listOfNotNull( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt index d04a1ee122d..38be6c7d91b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt @@ -99,7 +99,7 @@ internal fun MessageBody( messageBody: MessageBody?, isAvailable: Boolean, accent: Accent, - onOpenProfile: (String) -> Unit, + onOpenProfile: (senderId: MessageSenderId) -> Unit, buttonList: PersistentList?, onLinkClick: (String) -> Unit, searchQuery: String = "", 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 41b28e31c6a..b0e9ceb13e4 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 @@ -43,6 +43,7 @@ import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.Message import com.wire.kalium.logic.data.message.MessageAttachment import com.wire.kalium.logic.data.user.AssetId +import com.wire.kalium.logic.data.user.BotService import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.util.DateTimeUtil.toIsoDateTimeString @@ -163,9 +164,16 @@ data class MessageHeader( val isSenderUnavailable: Boolean, val clientId: ClientId? = null, val accent: Accent = Accent.Unknown, - val guestExpiresAt: Instant? = null + val guestExpiresAt: Instant? = null, + val senderId: MessageSenderId? = null ) +sealed interface MessageSenderId { + data class User(val id: String) : MessageSenderId + data class App(val appId: UserId) : MessageSenderId + data class Bot(val botService: BotService) : MessageSenderId +} + @Stable @Serializable data class MessageFooter( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModel.kt index 369ddcbc6a6..cbe24b11867 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModel.kt @@ -37,6 +37,7 @@ import com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryTyp import com.wire.android.ui.home.newconversation.common.CreateGroupState import com.wire.android.ui.home.newconversation.groupOptions.GroupOptionState import com.wire.android.ui.home.newconversation.model.Contact +import com.wire.android.util.AppsUtil import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.CreateConversationParam import com.wire.kalium.logic.data.user.UserId @@ -101,6 +102,10 @@ class NewConversationViewModel @Inject constructor( .collectLatest { isAppsAllowedResult -> groupOptionsState = groupOptionsState.copy( isTeamAllowedToUseApps = isAppsAllowedResult, + shouldShowNewAppsUi = AppsUtil.isAppsAllowed( + appsAllowedResult = isAppsAllowedResult, + conversationProtocol = null + ), isAllowAppsEnabled = isAppsAllowedResult is AppsAllowedResult.Enabled ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionState.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionState.kt index 5e331c394a4..c9b72f1f37b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionState.kt @@ -29,5 +29,6 @@ data class GroupOptionState( val showAllowGuestsDialog: Boolean = false, // feature flag for allowing apps usage for the team val isTeamAllowedToUseApps: AppsAllowedResult = AppsAllowedResult.Disabled, + val shouldShowNewAppsUi: Boolean = false, val isWireCellsEnabled: Boolean? = null, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/search/NewConversationSearchPeopleScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/search/NewConversationSearchPeopleScreen.kt index 769ced1edc2..4d0e5db38d0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/search/NewConversationSearchPeopleScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/search/NewConversationSearchPeopleScreen.kt @@ -29,10 +29,14 @@ import com.wire.android.navigation.style.PopUpNavigationAnimation import com.ramcosta.composedestinations.generated.app.navgraphs.PersonalToTeamMigrationGraph import com.ramcosta.composedestinations.generated.app.destinations.NewGroupConversationSearchPeopleScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.ServiceDetailsScreenDestination import com.wire.android.ui.home.conversations.search.SearchPeopleScreenType import com.wire.android.ui.home.conversations.search.SearchUsersAndAppsScreen import com.wire.android.ui.home.newconversation.NewConversationViewModel +import com.wire.android.ui.userprofile.service.ServiceDetailsNavArgs import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.user.BotService +import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.featureConfig.AppsAllowedResult @WireNewConversationDestination( @@ -69,8 +73,25 @@ fun NewConversationSearchPeopleScreen( isAppsTabVisible = (newConversationViewModel.groupOptionsState.isTeamAllowedToUseApps is AppsAllowedResult.Enabled), conversationProtocol = null, onAppClicked = { contact -> - OtherUserProfileScreenDestination(QualifiedID(contact.id, contact.domain)) - .let { navigator.navigate(NavigationCommand(it)) } + val serviceDetailsNavArgsId: ServiceDetailsNavArgs.Id = + if (newConversationViewModel.groupOptionsState.shouldShowNewAppsUi) { + ServiceDetailsNavArgs.Id.AppId( + UserId(contact.id, contact.domain) + ) + } else { + ServiceDetailsNavArgs.Id.BotServiceId( + BotService(id = contact.id, provider = contact.domain) + ) + } + + navigator.navigate( + NavigationCommand( + ServiceDetailsScreenDestination( + null, + serviceDetailsNavArgsId + ) + ) + ) } ) diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownText.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownText.kt index 6b83572b581..d31c6d73af4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownText.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownText.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import com.wire.android.ui.common.ClickableText +import com.wire.android.ui.home.conversations.model.MessageSenderId import com.wire.android.ui.markdown.MarkdownConstants.TAG_MENTION import com.wire.android.ui.markdown.MarkdownConstants.TAG_URL @@ -47,7 +48,7 @@ fun MarkdownText( clickable: Boolean = true, onClickLink: ((linkText: String) -> Unit)? = null, onLongClick: (() -> Unit)? = null, - onOpenProfile: ((String) -> Unit)? = null + onOpenProfile: ((senderId: MessageSenderId) -> Unit)? = null ) { if (clickable) { @@ -75,7 +76,7 @@ fun MarkdownText( start = offset, end = offset ).firstOrNull()?.let { result -> - onOpenProfile?.invoke(result.item) + onOpenProfile?.invoke(MessageSenderId.User(result.item)) } }, onLongClick = onLongClick diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/NodeData.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/NodeData.kt index 803639920c8..9376a44fdf7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/NodeData.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/NodeData.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import com.wire.android.ui.home.conversations.messages.item.MessageStyle +import com.wire.android.ui.home.conversations.model.MessageSenderId import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.WireColorScheme import com.wire.android.ui.theme.WireTypography @@ -45,7 +46,7 @@ data class MessageColors(val highlighted: Color) data class NodeActions( val onLongClick: (() -> Unit)? = null, - val onOpenProfile: (String) -> Unit, + val onOpenProfile: (senderId: MessageSenderId) -> Unit, val onLinkClick: (String) -> Unit ) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsInfoMessageType.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsInfoMessageType.kt index 40d0ac9605b..3c81b8c611d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsInfoMessageType.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsInfoMessageType.kt @@ -30,4 +30,9 @@ sealed class ServiceDetailsInfoMessageType(override val uiText: UIText) : SnackB // Add Service object SuccessAddService : ServiceDetailsInfoMessageType(UIText.StringResource(R.string.service_add_success)) object ErrorAddService : ServiceDetailsInfoMessageType(UIText.StringResource(R.string.service_add_error)) + + // Start or Open Conversation + object ErrorStartOrOpenConversation : ServiceDetailsInfoMessageType( + UIText.StringResource(R.string.service_conversation_creation_or_retrieval_error) + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavArgs.kt index f1ac415efa8..49fb12aa70d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavArgs.kt @@ -34,15 +34,22 @@ data class ServiceDetailsNavArgs( ) { sealed interface Id { val serviceId: ServiceId + val userId: UserId data class BotServiceId(val botService: BotService) : Id { override val serviceId: ServiceId get() = ServiceId(botService.id, botService.provider) + + override val userId: UserId + get() = UserId(botService.id, botService.provider) } data class AppId(val appId: UserId) : Id { override val serviceId: ServiceId get() = ServiceId(appId.value, appId.domain) + + override val userId: UserId + get() = appId } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsScreen.kt index 8a9b579cc69..8c4881a0aed 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsScreen.kt @@ -31,36 +31,43 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import com.wire.android.di.wireViewModel +import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination import com.wire.android.R +import com.wire.android.di.hiltViewModelScoped import com.wire.android.model.ClickBlockParams import com.wire.android.model.NameBasedAvatar import com.wire.android.model.UserAvatarData import com.wire.android.ui.common.avatar.UserProfileAvatar import com.wire.android.ui.common.avatar.UserProfileAvatarType import com.wire.android.model.Clickable +import com.wire.android.navigation.BackStackMode +import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.UserBadge +import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.kalium.logic.data.service.ServiceDetails +import kotlinx.coroutines.launch @WireRootDestination( navArgs = ServiceDetailsNavArgs::class, @@ -69,30 +76,50 @@ import com.wire.kalium.logic.data.service.ServiceDetails @Composable fun ServiceDetailsScreen( navigator: Navigator, - viewModel: ServiceDetailsViewModel = wireViewModel() + viewModel: ServiceDetailsViewModel = + hiltViewModelScoped() ) { val snackbarHostState = LocalSnackbarHostState.current + val coroutineScope = rememberCoroutineScope() val context = LocalContext.current - LaunchedEffect(Unit) { - viewModel.infoMessage.collect { - snackbarHostState.showSnackbar(it.asString(context.resources)) + HandleActions(viewModel.actions) { action -> + when (action) { + is ServiceDetailsViewActions.Message -> + coroutineScope.launch { + snackbarHostState + .showSnackbar(action.message.uiText.asString(context.resources)) + } + + is ServiceDetailsViewActions.OpenConversation -> + navigator.navigate( + NavigationCommand( + ConversationScreenDestination( + navArgs = ConversationNavArgs( + conversationId = action.conversationId + ) + ), + BackStackMode.UPDATE_EXISTED + ) + ) } } ServiceDetailsContent( navigateBack = navigator::navigateBack, - addService = viewModel::addService, - removeService = viewModel::removeService, - serviceDetailsState = viewModel.serviceDetailsState + onAddService = viewModel::onAddService, + onRemoveService = viewModel::onRemoveService, + onOpenConversation = viewModel::onOpenConversation, + serviceDetailsState = viewModel.serviceDetailsState() ) } @Composable private fun ServiceDetailsContent( navigateBack: () -> Unit, - addService: () -> Unit, - removeService: () -> Unit, + onAddService: () -> Unit, + onRemoveService: () -> Unit, + onOpenConversation: () -> Unit, serviceDetailsState: ServiceDetailsState ) { WireScaffold( @@ -112,10 +139,11 @@ private fun ServiceDetailsContent( } }, bottomBar = { - ServiceDetailsAddOrRemoveButton( - buttonState = serviceDetailsState.buttonState, - addService = addService, - removeService = removeService + ServiceDetailsButtons( + serviceDetailsState = serviceDetailsState, + onAddService = onAddService, + onRemoveService = onRemoveService, + onOpenConversation = onOpenConversation ) } ) @@ -223,8 +251,9 @@ private fun ServiceDetailsDescription(serviceDetails: ServiceDetails) { @Composable private fun ServiceDetailsAddOrRemoveButton( buttonState: ServiceDetailsButtonState, - addService: () -> Unit, - removeService: () -> Unit + isActionLoading: Boolean, + onAddService: () -> Unit, + onRemoveService: () -> Unit ) { val (shouldShow: Boolean, textString: String?) = when (buttonState) { ServiceDetailsButtonState.HIDDEN -> Pair(false, null) @@ -242,8 +271,10 @@ private fun ServiceDetailsAddOrRemoveButton( verticalAlignment = Alignment.CenterVertically ) { WirePrimaryButton( + loading = isActionLoading, + state = if (isActionLoading) WireButtonState.Disabled else WireButtonState.Default, text = textString, - onClick = if (buttonState == ServiceDetailsButtonState.ADD) addService else removeService, + onClick = if (buttonState == ServiceDetailsButtonState.ADD) onAddService else onRemoveService, clickBlockParams = ClickBlockParams(blockWhenSyncing = true, blockWhenConnecting = true), modifier = Modifier .weight(1f) @@ -254,8 +285,78 @@ private fun ServiceDetailsAddOrRemoveButton( } } +@Composable +private fun ServiceDetailsStartOrOpenConversation( + isDataLoading: Boolean, + isActionLoading: Boolean, + isConversationStarted: Boolean, + onOpenConversation: () -> Unit +) { + if (!isDataLoading) { + Surface( + color = MaterialTheme.wireColorScheme.background, + shadowElevation = MaterialTheme.wireDimensions.bottomNavigationShadowElevation + ) { + HorizontalDivider(color = colorsScheme().outline) + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + WirePrimaryButton( + loading = isActionLoading, + state = if (isActionLoading) WireButtonState.Disabled else WireButtonState.Default, + text = stringResource( + id = if (isConversationStarted) { + R.string.label_open_conversation + } else { + R.string.label_start_conversation + } + ), + onClick = onOpenConversation, + clickBlockParams = ClickBlockParams( + blockWhenSyncing = true, + blockWhenConnecting = true + ), + modifier = Modifier + .weight(1f) + .padding(dimensions().spacing16x) + ) + } + } + } +} + +@Composable +private fun ServiceDetailsButtons( + serviceDetailsState: ServiceDetailsState, + onAddService: () -> Unit, + onRemoveService: () -> Unit, + onOpenConversation: () -> Unit +) { + when { + serviceDetailsState.conversationId != null -> ServiceDetailsAddOrRemoveButton( + buttonState = serviceDetailsState.buttonState, + isActionLoading = serviceDetailsState.isActionLoading, + onAddService = onAddService, + onRemoveService = onRemoveService + ) + serviceDetailsState.isAppsEnabled -> ServiceDetailsStartOrOpenConversation( + isDataLoading = serviceDetailsState.isDataLoading, + isActionLoading = serviceDetailsState.isActionLoading, + isConversationStarted = serviceDetailsState.isConversationStarted, + onOpenConversation = onOpenConversation + ) + } +} + @Preview @Composable fun PreviewServiceDetailsScreen() { - ServiceDetailsContent({}, {}, {}, ServiceDetailsState()) + ServiceDetailsContent( + navigateBack = {}, + onAddService = {}, + onRemoveService = {}, + onOpenConversation = {}, + serviceDetailsState = ServiceDetailsState() + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsState.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsState.kt index 2cb19afa160..ab1449a3964 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsState.kt @@ -31,8 +31,11 @@ data class ServiceDetailsState( val serviceAvatarAsset: ImageAsset.UserAvatarAsset? = null, val isDataLoading: Boolean = false, val isAvatarLoading: Boolean = false, + val isActionLoading: Boolean = false, + val isConversationStarted: Boolean = false, val buttonState: ServiceDetailsButtonState = ServiceDetailsButtonState.HIDDEN, - val serviceMemberId: QualifiedID? = null + val serviceMemberId: QualifiedID? = null, + val isAppsEnabled: Boolean = false ) data class ServiceDetailsGroupState( diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewActions.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewActions.kt new file mode 100644 index 00000000000..5217254ec6f --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewActions.kt @@ -0,0 +1,26 @@ +/* + * Wire + * Copyright (C) 2026 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.userprofile.service + +import com.wire.android.model.SnackBarMessage +import com.wire.kalium.logic.data.id.ConversationId + +sealed interface ServiceDetailsViewActions { + data class Message(val message: SnackBarMessage) : ServiceDetailsViewActions + data class OpenConversation(val conversationId: ConversationId) : ServiceDetailsViewActions +} diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModel.kt index 00940bb6f9a..53f483c9df2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModel.kt @@ -21,15 +21,18 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.di.CurrentAccount import com.wire.android.model.ImageAsset import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveConversationRoleForUserUseCase import com.ramcosta.composedestinations.generated.app.navArgs +import com.wire.android.appLogger +import com.wire.android.di.ViewModelScopedPreview +import com.wire.android.model.asSnackBarMessage +import com.wire.android.ui.common.ActionsManager +import com.wire.android.ui.common.ActionsViewModel import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.AppsUtil -import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.service.ServiceDetails @@ -40,6 +43,9 @@ import com.wire.kalium.logic.feature.app.ObserveIsAppMemberResult import com.wire.kalium.logic.feature.app.ObserveIsAppMemberUseCase import com.wire.kalium.logic.feature.conversation.AddMemberToConversationUseCase import com.wire.kalium.logic.feature.conversation.AddServiceToConversationUseCase +import com.wire.kalium.logic.feature.conversation.CreateConversationResult +import com.wire.kalium.logic.feature.conversation.GetOrCreateOneToOneConversationUseCase +import com.wire.kalium.logic.feature.conversation.IsOneToOneConversationCreatedUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.conversation.RemoveMemberFromConversationUseCase import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppsAllowedForUsageUseCase @@ -48,8 +54,6 @@ import com.wire.kalium.logic.feature.service.ObserveIsServiceMemberResult import com.wire.kalium.logic.feature.service.ObserveIsServiceMemberUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.firstOrNull @@ -60,9 +64,17 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject +@ViewModelScopedPreview +interface ServiceDetailsViewModel : ActionsManager { + fun serviceDetailsState(): ServiceDetailsState = ServiceDetailsState() + fun onAddService() {} + fun onRemoveService() {} + fun onOpenConversation() {} +} + @Suppress("LongParameterList") @HiltViewModel -class ServiceDetailsViewModel @Inject constructor( +class ServiceDetailsViewModelImpl @Inject constructor( private val dispatchers: DispatcherProvider, @CurrentAccount private val selfUserId: UserId, private val getServiceById: GetServiceByIdUseCase, @@ -75,28 +87,23 @@ class ServiceDetailsViewModel @Inject constructor( private val removeMemberFromConversation: RemoveMemberFromConversationUseCase, private val addServiceToConversation: AddServiceToConversationUseCase, private val addMemberToConversation: AddMemberToConversationUseCase, + private val isOneToOneConversationCreated: IsOneToOneConversationCreatedUseCase, + private val getOrCreateOneToOneConversation: GetOrCreateOneToOneConversationUseCase, savedStateHandle: SavedStateHandle -) : ViewModel() { +) : ServiceDetailsViewModel, ActionsViewModel() { private val serviceDetailsNavArgs: ServiceDetailsNavArgs = savedStateHandle.navArgs() private val serviceId: ServiceId = serviceDetailsNavArgs.id.serviceId + private val userId: UserId = serviceDetailsNavArgs.id.userId private val conversationId: QualifiedID? = serviceDetailsNavArgs.conversationId - var serviceDetailsState by mutableStateOf(ServiceDetailsState()) - var isAppsEnabled by mutableStateOf(false) - - private val _infoMessage = MutableSharedFlow() - val infoMessage = _infoMessage.asSharedFlow() + private var state by mutableStateOf(ServiceDetailsState()) + override fun serviceDetailsState(): ServiceDetailsState = state init { viewModelScope.launch { - serviceDetailsState = serviceDetailsState.copy( - serviceId = serviceId, - conversationId = conversationId, - isDataLoading = true, - isAvatarLoading = true - ) + getIfConversationExist() val appsAllowedResult = observeIsAppsAllowedForUsage().firstOrNull() @@ -107,27 +114,39 @@ class ServiceDetailsViewModel @Inject constructor( .firstOrNull() } - isAppsEnabled = AppsUtil.isAppsAllowed( + val isAppsEnabled = AppsUtil.isAppsAllowed( appsAllowedResult = appsAllowedResult, conversationProtocol = conversationProtocolInfo ) - when { - isAppsEnabled && serviceDetailsNavArgs.id is ServiceDetailsNavArgs.Id.AppId -> { - getAppDetailsAndUpdateViewState(serviceDetailsNavArgs.id.appId) - ?.let { observeIsAppConversationMember(serviceDetailsNavArgs.id.appId) } + state = state.copy( + serviceId = serviceId, + conversationId = conversationId, + isDataLoading = true, + isAvatarLoading = true, + isAppsEnabled = isAppsEnabled + ) + + when (val id = serviceDetailsNavArgs.id) { + is ServiceDetailsNavArgs.Id.AppId -> { + val details = getAppDetailsAndUpdateViewState(id.appId) + if (details != null && isAppsEnabled) { + observeIsAppConversationMember(id.appId) + } } - !isAppsEnabled && serviceDetailsNavArgs.id is ServiceDetailsNavArgs.Id.BotServiceId -> { - getServiceDetailsAndUpdateViewState() - ?.let { observeIsServiceConversationMember() } + + is ServiceDetailsNavArgs.Id.BotServiceId -> { + getServiceDetailsAndUpdateViewState()?.let { + observeIsServiceConversationMember() + } } - else -> serviceNotFound() } } } - fun addService() { + override fun onAddService() { viewModelScope.launch { + state = state.copy(isActionLoading = true) val responseMessage = when (val id = serviceDetailsNavArgs.id) { is ServiceDetailsNavArgs.Id.AppId -> { val response = addMemberToConversation.invoke( @@ -155,13 +174,15 @@ class ServiceDetailsViewModel @Inject constructor( } } - _infoMessage.emit(responseMessage.uiText) + sendAction(ServiceDetailsViewActions.Message(responseMessage.uiText.asSnackBarMessage())) + state = state.copy(isActionLoading = false) } } - fun removeService() { + override fun onRemoveService() { viewModelScope.launch { - serviceDetailsState.serviceMemberId?.let { serviceMemberId -> + state.serviceMemberId?.let { serviceMemberId -> + state = state.copy(isActionLoading = true) val response = withContext(dispatchers.io()) { removeMemberFromConversation( conversationId = requireNotNull(conversationId), @@ -174,11 +195,39 @@ class ServiceDetailsViewModel @Inject constructor( is RemoveMemberFromConversationUseCase.Result.Success -> ServiceDetailsInfoMessageType.SuccessRemoveService } - _infoMessage.emit(responseMessage.uiText) + sendAction(ServiceDetailsViewActions.Message(responseMessage.uiText.asSnackBarMessage())) + state = state.copy(isActionLoading = false) } } } + override fun onOpenConversation() { + viewModelScope.launch { + state = state.copy(isActionLoading = true) + val result = withContext(dispatchers.io()) { + getOrCreateOneToOneConversation(userId) + } + + when (result) { + is CreateConversationResult.Failure -> { + appLogger.d("Couldn't retrieve or create the conversation") + + sendAction( + ServiceDetailsViewActions.Message( + ServiceDetailsInfoMessageType + .ErrorStartOrOpenConversation + .uiText + .asSnackBarMessage() + ) + ) + } + is CreateConversationResult.Success -> + sendAction(ServiceDetailsViewActions.OpenConversation(result.conversation.id)) + } + state = state.copy(isActionLoading = false) + } + } + private suspend fun getServiceDetailsAndUpdateViewState(): ServiceDetails? = getServiceById(serviceId = serviceId).also { service -> if (service != null) { @@ -186,7 +235,7 @@ class ServiceDetailsViewModel @Inject constructor( ImageAsset.UserAvatarAsset(asset) } - serviceDetailsState = serviceDetailsState.copy( + state = state.copy( isDataLoading = false, isAvatarLoading = false, serviceAvatarAsset = serviceAvatarAsset, @@ -206,7 +255,7 @@ class ServiceDetailsViewModel @Inject constructor( ImageAsset.UserAvatarAsset(asset) } - serviceDetailsState = serviceDetailsState.copy( + state = state.copy( isDataLoading = false, isAvatarLoading = false, serviceAvatarAsset = appAvatarAsset, @@ -265,8 +314,17 @@ class ServiceDetailsViewModel @Inject constructor( } } + private fun getIfConversationExist() { + viewModelScope.launch { + if (conversationId == null) { + val isOneToOneConversationCreated = isOneToOneConversationCreated(userId) + state = state.copy(isConversationStarted = isOneToOneConversationCreated) + } + } + } + private fun serviceNotFound() { - serviceDetailsState = serviceDetailsState.copy( + state = state.copy( serviceDetails = null, buttonState = ServiceDetailsButtonState.HIDDEN ) @@ -286,7 +344,7 @@ class ServiceDetailsViewModel @Inject constructor( ServiceDetailsButtonState.HIDDEN } - serviceDetailsState = serviceDetailsState.copy( + state = state.copy( buttonState = buttonState, serviceMemberId = serviceMemberId ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 39493419d3e..20c0512469f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1261,6 +1261,7 @@ Unable to remove app from conversation App added to conversation Unable to add app to conversation + Couldn\'t retrieve or create the conversation No information available Try again or reach out to your team admin Read receipts diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt index d598fedad15..834bc59b8be 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt @@ -217,6 +217,8 @@ internal fun withMockConversationDetailsOneOnOne( every { isUnavailableUser } returns unavailable every { deleted } returns false every { accentId } returns 0 + every { botService } returns null + every { userType } returns UserTypeInfo.Regular(UserType.NONE) }, userType = UserTypeInfo.Regular(UserType.INTERNAL), ) diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetQuoteMessageForConversationUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetQuoteMessageForConversationUseCaseTest.kt index 816f6e469a8..3501bd88fe0 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetQuoteMessageForConversationUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetQuoteMessageForConversationUseCaseTest.kt @@ -26,6 +26,7 @@ import com.wire.android.ui.home.conversations.model.MessageBody import com.wire.android.ui.home.conversations.model.MessageFlowStatus import com.wire.android.ui.home.conversations.model.MessageFooter import com.wire.android.ui.home.conversations.model.MessageHeader +import com.wire.android.ui.home.conversations.model.MessageSenderId import com.wire.android.ui.home.conversations.model.MessageSource import com.wire.android.ui.home.conversations.model.MessageStatus import com.wire.android.ui.home.conversations.model.MessageTime @@ -246,6 +247,7 @@ class GetQuoteMessageForConversationUseCaseTest { connectionState = null, isSenderDeleted = false, isSenderUnavailable = false, + senderId = MessageSenderId.User(USER_ID.toString()) ), source = MessageSource.OtherUser, userAvatarData = UserAvatarData(), diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModelTest.kt index 90bb34f4ea1..43db9d38676 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModelTest.kt @@ -24,6 +24,7 @@ import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.framework.TestUser +import com.wire.android.model.asSnackBarMessage import com.wire.android.ui.home.conversations.details.participants.usecase.ConversationRoleData import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveConversationRoleForUserUseCase import com.ramcosta.composedestinations.generated.app.navArgs @@ -44,6 +45,9 @@ import com.wire.kalium.logic.feature.app.ObserveIsAppMemberResult import com.wire.kalium.logic.feature.app.ObserveIsAppMemberUseCase import com.wire.kalium.logic.feature.conversation.AddMemberToConversationUseCase import com.wire.kalium.logic.feature.conversation.AddServiceToConversationUseCase +import com.wire.kalium.logic.feature.conversation.CreateConversationResult +import com.wire.kalium.logic.feature.conversation.GetOrCreateOneToOneConversationUseCase +import com.wire.kalium.logic.feature.conversation.IsOneToOneConversationCreatedUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.conversation.RemoveMemberFromConversationUseCase import com.wire.kalium.logic.feature.featureConfig.AppsAllowedProtocol @@ -84,10 +88,10 @@ class ServiceDetailsViewModelTest { // view model is initialized // then - assertEquals(null, viewModel.serviceDetailsState.serviceAvatarAsset) - assertEquals(SERVICE_DETAILS, viewModel.serviceDetailsState.serviceDetails) - assertEquals(MEMBER_ID, viewModel.serviceDetailsState.serviceMemberId) - assertEquals(ServiceDetailsButtonState.REMOVE, viewModel.serviceDetailsState.buttonState) + assertEquals(null, viewModel.serviceDetailsState().serviceAvatarAsset) + assertEquals(SERVICE_DETAILS, viewModel.serviceDetailsState().serviceDetails) + assertEquals(MEMBER_ID, viewModel.serviceDetailsState().serviceMemberId) + assertEquals(ServiceDetailsButtonState.REMOVE, viewModel.serviceDetailsState().buttonState) } @Test @@ -106,10 +110,10 @@ class ServiceDetailsViewModelTest { // view model is initialized // then - assertEquals(null, viewModel.serviceDetailsState.serviceAvatarAsset) - assertEquals(SERVICE_DETAILS, viewModel.serviceDetailsState.serviceDetails) - assertEquals(MEMBER_ID, viewModel.serviceDetailsState.serviceMemberId) - assertEquals(ServiceDetailsButtonState.REMOVE, viewModel.serviceDetailsState.buttonState) + assertEquals(null, viewModel.serviceDetailsState().serviceAvatarAsset) + assertEquals(SERVICE_DETAILS, viewModel.serviceDetailsState().serviceDetails) + assertEquals(MEMBER_ID, viewModel.serviceDetailsState().serviceMemberId) + assertEquals(ServiceDetailsButtonState.REMOVE, viewModel.serviceDetailsState().buttonState) } @Test @@ -128,10 +132,10 @@ class ServiceDetailsViewModelTest { // view model is initialized // then - assertEquals(null, viewModel.serviceDetailsState.serviceAvatarAsset) - assertEquals(SERVICE_DETAILS, viewModel.serviceDetailsState.serviceDetails) - assertEquals(MEMBER_ID, viewModel.serviceDetailsState.serviceMemberId) - assertEquals(ServiceDetailsButtonState.REMOVE, viewModel.serviceDetailsState.buttonState) + assertEquals(null, viewModel.serviceDetailsState().serviceAvatarAsset) + assertEquals(SERVICE_DETAILS, viewModel.serviceDetailsState().serviceDetails) + assertEquals(MEMBER_ID, viewModel.serviceDetailsState().serviceMemberId) + assertEquals(ServiceDetailsButtonState.REMOVE, viewModel.serviceDetailsState().buttonState) } @Test @@ -149,9 +153,9 @@ class ServiceDetailsViewModelTest { // view model is initialized // then - assertEquals(SERVICE_DETAILS, viewModel.serviceDetailsState.serviceDetails) - assertEquals(null, viewModel.serviceDetailsState.serviceMemberId) - assertEquals(ServiceDetailsButtonState.ADD, viewModel.serviceDetailsState.buttonState) + assertEquals(SERVICE_DETAILS, viewModel.serviceDetailsState().serviceDetails) + assertEquals(null, viewModel.serviceDetailsState().serviceMemberId) + assertEquals(ServiceDetailsButtonState.ADD, viewModel.serviceDetailsState().buttonState) } @Test @@ -174,9 +178,9 @@ class ServiceDetailsViewModelTest { // view model is initialized // then - assertEquals(SERVICE_DETAILS, viewModel.serviceDetailsState.serviceDetails) - assertEquals(MEMBER_ID, viewModel.serviceDetailsState.serviceMemberId) - assertEquals(ServiceDetailsButtonState.HIDDEN, viewModel.serviceDetailsState.buttonState) + assertEquals(SERVICE_DETAILS, viewModel.serviceDetailsState().serviceDetails) + assertEquals(MEMBER_ID, viewModel.serviceDetailsState().serviceMemberId) + assertEquals(ServiceDetailsButtonState.HIDDEN, viewModel.serviceDetailsState().buttonState) } @Test @@ -194,9 +198,9 @@ class ServiceDetailsViewModelTest { // view model is initialized // then - assertEquals(null, viewModel.serviceDetailsState.serviceDetails) - assertEquals(null, viewModel.serviceDetailsState.serviceMemberId) - assertEquals(ServiceDetailsButtonState.HIDDEN, viewModel.serviceDetailsState.buttonState) + assertEquals(null, viewModel.serviceDetailsState().serviceDetails) + assertEquals(null, viewModel.serviceDetailsState().serviceMemberId) + assertEquals(ServiceDetailsButtonState.HIDDEN, viewModel.serviceDetailsState().buttonState) } @Test @@ -212,8 +216,8 @@ class ServiceDetailsViewModelTest { .arrange() // when - viewModel.infoMessage.test { - viewModel.removeService() + viewModel.actions.test { + viewModel.onRemoveService() // then coVerify(exactly = 1) { @@ -222,7 +226,12 @@ class ServiceDetailsViewModelTest { userIdToRemove = MEMBER_ID ) } - assertEquals(ServiceDetailsInfoMessageType.SuccessRemoveService.uiText, awaitItem()) + assertEquals( + ServiceDetailsViewActions.Message( + ServiceDetailsInfoMessageType.SuccessRemoveService.uiText.asSnackBarMessage() + ), + awaitItem() + ) } } @@ -239,8 +248,8 @@ class ServiceDetailsViewModelTest { .arrange() // when - viewModel.infoMessage.test { - viewModel.removeService() + viewModel.actions.test { + viewModel.onRemoveService() // then coVerify(exactly = 1) { @@ -249,7 +258,12 @@ class ServiceDetailsViewModelTest { userIdToRemove = MEMBER_ID ) } - assertEquals(ServiceDetailsInfoMessageType.ErrorRemoveService.uiText, awaitItem()) + assertEquals( + ServiceDetailsViewActions.Message( + ServiceDetailsInfoMessageType.ErrorRemoveService.uiText.asSnackBarMessage() + ), + awaitItem() + ) } } @@ -266,8 +280,8 @@ class ServiceDetailsViewModelTest { .arrange() // when - viewModel.infoMessage.test { - viewModel.addService() + viewModel.actions.test { + viewModel.onAddService() // then coVerify(exactly = 1) { @@ -276,7 +290,12 @@ class ServiceDetailsViewModelTest { serviceId = SERVICE_ID ) } - assertEquals(ServiceDetailsInfoMessageType.SuccessAddService.uiText, awaitItem()) + assertEquals( + ServiceDetailsViewActions.Message( + ServiceDetailsInfoMessageType.SuccessAddService.uiText.asSnackBarMessage() + ), + awaitItem() + ) } } @@ -293,8 +312,8 @@ class ServiceDetailsViewModelTest { .arrange() // when - viewModel.infoMessage.test { - viewModel.addService() + viewModel.actions.test { + viewModel.onAddService() // then coVerify(exactly = 1) { @@ -303,7 +322,12 @@ class ServiceDetailsViewModelTest { serviceId = SERVICE_ID ) } - assertEquals(ServiceDetailsInfoMessageType.ErrorAddService.uiText, awaitItem()) + assertEquals( + ServiceDetailsViewActions.Message( + ServiceDetailsInfoMessageType.ErrorAddService.uiText.asSnackBarMessage() + ), + awaitItem() + ) } } @@ -324,10 +348,10 @@ class ServiceDetailsViewModelTest { // view model is initialized // then - assertEquals(null, viewModel.serviceDetailsState.serviceAvatarAsset) - assertEquals(APP_SERVICE_DETAILS, viewModel.serviceDetailsState.serviceDetails) - assertEquals(APP_ID, viewModel.serviceDetailsState.serviceMemberId) - assertEquals(ServiceDetailsButtonState.REMOVE, viewModel.serviceDetailsState.buttonState) + assertEquals(null, viewModel.serviceDetailsState().serviceAvatarAsset) + assertEquals(APP_SERVICE_DETAILS, viewModel.serviceDetailsState().serviceDetails) + assertEquals(APP_ID, viewModel.serviceDetailsState().serviceMemberId) + assertEquals(ServiceDetailsButtonState.REMOVE, viewModel.serviceDetailsState().buttonState) } @Test @@ -347,9 +371,9 @@ class ServiceDetailsViewModelTest { // view model is initialized // then - assertEquals(APP_SERVICE_DETAILS, viewModel.serviceDetailsState.serviceDetails) - assertEquals(null, viewModel.serviceDetailsState.serviceMemberId) - assertEquals(ServiceDetailsButtonState.ADD, viewModel.serviceDetailsState.buttonState) + assertEquals(APP_SERVICE_DETAILS, viewModel.serviceDetailsState().serviceDetails) + assertEquals(null, viewModel.serviceDetailsState().serviceMemberId) + assertEquals(ServiceDetailsButtonState.ADD, viewModel.serviceDetailsState().buttonState) } @Test @@ -370,9 +394,11 @@ class ServiceDetailsViewModelTest { // view model is initialized // then - assertEquals(APP_SERVICE_DETAILS, viewModel.serviceDetailsState.serviceDetails) - assertEquals(null, viewModel.serviceDetailsState.serviceMemberId) - assertEquals(ServiceDetailsButtonState.HIDDEN, viewModel.serviceDetailsState.buttonState) + assertEquals(APP_SERVICE_DETAILS, viewModel.serviceDetailsState().serviceDetails) + assertEquals(null, viewModel.serviceDetailsState().serviceMemberId) + assertEquals(true, viewModel.serviceDetailsState().isAppsEnabled) + assertEquals(false, viewModel.serviceDetailsState().isConversationStarted) + assertEquals(ServiceDetailsButtonState.HIDDEN, viewModel.serviceDetailsState().buttonState) } @Test @@ -397,9 +423,9 @@ class ServiceDetailsViewModelTest { // view model is initialized // then - assertEquals(APP_SERVICE_DETAILS, viewModel.serviceDetailsState.serviceDetails) - assertEquals(APP_ID, viewModel.serviceDetailsState.serviceMemberId) - assertEquals(ServiceDetailsButtonState.HIDDEN, viewModel.serviceDetailsState.buttonState) + assertEquals(APP_SERVICE_DETAILS, viewModel.serviceDetailsState().serviceDetails) + assertEquals(APP_ID, viewModel.serviceDetailsState().serviceMemberId) + assertEquals(ServiceDetailsButtonState.HIDDEN, viewModel.serviceDetailsState().buttonState) } @Test @@ -419,9 +445,9 @@ class ServiceDetailsViewModelTest { // view model is initialized // then - assertEquals(null, viewModel.serviceDetailsState.serviceDetails) - assertEquals(null, viewModel.serviceDetailsState.serviceMemberId) - assertEquals(ServiceDetailsButtonState.HIDDEN, viewModel.serviceDetailsState.buttonState) + assertEquals(null, viewModel.serviceDetailsState().serviceDetails) + assertEquals(null, viewModel.serviceDetailsState().serviceMemberId) + assertEquals(ServiceDetailsButtonState.HIDDEN, viewModel.serviceDetailsState().buttonState) } @Test @@ -439,8 +465,8 @@ class ServiceDetailsViewModelTest { .arrange() // when - viewModel.infoMessage.test { - viewModel.removeService() + viewModel.actions.test { + viewModel.onRemoveService() // then coVerify(exactly = 1) { @@ -450,7 +476,12 @@ class ServiceDetailsViewModelTest { ) } - assertEquals(ServiceDetailsInfoMessageType.SuccessRemoveService.uiText, awaitItem()) + assertEquals( + ServiceDetailsViewActions.Message( + ServiceDetailsInfoMessageType.SuccessRemoveService.uiText.asSnackBarMessage() + ), + awaitItem() + ) } } @@ -469,8 +500,8 @@ class ServiceDetailsViewModelTest { .arrange() // when - viewModel.infoMessage.test { - viewModel.removeService() + viewModel.actions.test { + viewModel.onRemoveService() // then coVerify(exactly = 1) { @@ -480,7 +511,12 @@ class ServiceDetailsViewModelTest { ) } - assertEquals(ServiceDetailsInfoMessageType.ErrorRemoveService.uiText, awaitItem()) + assertEquals( + ServiceDetailsViewActions.Message( + ServiceDetailsInfoMessageType.ErrorRemoveService.uiText.asSnackBarMessage() + ), + awaitItem() + ) } } @@ -499,8 +535,8 @@ class ServiceDetailsViewModelTest { .arrange() // when - viewModel.infoMessage.test { - viewModel.addService() + viewModel.actions.test { + viewModel.onAddService() // then coVerify(exactly = 1) { @@ -510,7 +546,12 @@ class ServiceDetailsViewModelTest { ) } - assertEquals(ServiceDetailsInfoMessageType.SuccessAddService.uiText, awaitItem()) + assertEquals( + ServiceDetailsViewActions.Message( + ServiceDetailsInfoMessageType.SuccessAddService.uiText.asSnackBarMessage() + ), + awaitItem() + ) } } @@ -529,8 +570,8 @@ class ServiceDetailsViewModelTest { .arrange() // when - viewModel.infoMessage.test { - viewModel.addService() + viewModel.actions.test { + viewModel.onAddService() // then coVerify(exactly = 1) { @@ -540,7 +581,96 @@ class ServiceDetailsViewModelTest { ) } - assertEquals(ServiceDetailsInfoMessageType.ErrorAddService.uiText, awaitItem()) + assertEquals( + ServiceDetailsViewActions.Message( + ServiceDetailsInfoMessageType.ErrorAddService.uiText.asSnackBarMessage() + ), + awaitItem() + ) + } + } + + @Test + fun `given user opens service details screen from create conversation flow, when one to one conversation already exists, then conversation started state is shown`() = + runTest { + // given + val (_, viewModel) = Arrangement() + .withServiceApp( + service = APP_ID, + conversationId = null + ) + .withAppsAllowedForUsage(AppsAllowedResult.Enabled(AppsAllowedProtocol.MIXED(SupportedProtocol.MLS))) + .withAppDetails(APP_ID, APP_SERVICE_DETAILS) + .withOneToOneConversationCreated(isCreated = true) + .arrange() + + // when + // view model is initialized + + // then + assertEquals(true, viewModel.serviceDetailsState().isConversationStarted) + } + + @Test + fun `given user opens service details screen, when opening conversation succeeds, then conversation event is emitted`() = + runTest { + // given + val (arrangement, viewModel) = Arrangement() + .withServiceApp( + service = APP_ID, + conversationId = null + ) + .withAppsAllowedForUsage(AppsAllowedResult.Enabled(AppsAllowedProtocol.MIXED(SupportedProtocol.MLS))) + .withAppDetails(APP_ID, APP_SERVICE_DETAILS) + .withGetOrCreateOneToOneConversation( + CreateConversationResult.Success( + conversation = TestConversation.ONE_ON_ONE.copy(id = CONVERSATION_ID) + ) + ) + .arrange() + + // when + viewModel.actions.test { + viewModel.onOpenConversation() + + // then + coVerify(exactly = 1) { + arrangement.getOrCreateOneToOneConversation(APP_ID) + } + assertEquals(ServiceDetailsViewActions.OpenConversation(CONVERSATION_ID), awaitItem()) + } + } + + @Test + fun `given user opens service details screen, when opening conversation fails, then error message is emitted`() = + runTest { + // given + val (arrangement, viewModel) = Arrangement() + .withServiceApp( + service = APP_ID, + conversationId = null + ) + .withAppsAllowedForUsage(AppsAllowedResult.Enabled(AppsAllowedProtocol.MIXED(SupportedProtocol.MLS))) + .withAppDetails(APP_ID, APP_SERVICE_DETAILS) + .withGetOrCreateOneToOneConversation( + CreateConversationResult.Failure(CoreFailure.Unknown(null)) + ) + .arrange() + + // when + viewModel.actions.test { + viewModel.onOpenConversation() + + // then + coVerify(exactly = 1) { + arrangement.getOrCreateOneToOneConversation(APP_ID) + } + assertEquals( + ServiceDetailsViewActions.Message( + ServiceDetailsInfoMessageType.ErrorStartOrOpenConversation.uiText.asSnackBarMessage() + ), + awaitItem() + ) } } @@ -621,13 +751,19 @@ class ServiceDetailsViewModelTest { @MockK lateinit var addMemberToConversation: AddMemberToConversationUseCase + @MockK + lateinit var isOneToOneConversationCreated: IsOneToOneConversationCreatedUseCase + + @MockK + lateinit var getOrCreateOneToOneConversation: GetOrCreateOneToOneConversationUseCase + @MockK lateinit var savedStateHandle: SavedStateHandle private val selfUser = TestUser.SELF_USER private val viewModel by lazy { - ServiceDetailsViewModel( + ServiceDetailsViewModelImpl( TestDispatcherProvider(), selfUserId = selfUser.id, getServiceById, @@ -640,6 +776,8 @@ class ServiceDetailsViewModelTest { removeMemberFromConversation, addServiceToConversation, addMemberToConversation, + isOneToOneConversationCreated, + getOrCreateOneToOneConversation, savedStateHandle ) } @@ -656,6 +794,9 @@ class ServiceDetailsViewModelTest { coEvery { observeConversationDetails(any()) } returns flowOf(ObserveConversationDetailsUseCase.Result.Success(CONVERSATION_GROUP)) + coEvery { + isOneToOneConversationCreated(any()) + } returns false } fun withServiceBot(service: BotService, conversationId: ConversationId? = CONVERSATION_ID) = apply { @@ -722,6 +863,14 @@ class ServiceDetailsViewModelTest { coEvery { addServiceToConversation(any(), any()) } returns result } + fun withOneToOneConversationCreated(isCreated: Boolean) = apply { + coEvery { isOneToOneConversationCreated(any()) } returns isCreated + } + + fun withGetOrCreateOneToOneConversation(result: CreateConversationResult) = apply { + coEvery { getOrCreateOneToOneConversation(any()) } returns result + } + fun arrange() = this to viewModel } }