diff --git a/.github/workflows/jira-lint-and-link.yml b/.github/workflows/jira-lint-and-link.yml index c8155fb9463..52febc8d8d3 100644 --- a/.github/workflows/jira-lint-and-link.yml +++ b/.github/workflows/jira-lint-and-link.yml @@ -9,7 +9,7 @@ jobs: # Run only if the PR is not from a Fork / external contributor if: (!startsWith(github.ref, 'refs/heads/dependabot/') && github.repository_owner == 'wireapp') steps: - - uses: cakeinpanic/jira-description-action@v0.6.1 + - uses: cakeinpanic/jira-description-action@v0.7.0 name: jira-description-action with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt index 8ef2c383236..641cb09b934 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt @@ -38,6 +38,7 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationListDetails import com.wire.kalium.logic.feature.conversation.ObserveConversationMembersUseCase import com.wire.kalium.logic.feature.conversation.ObserveIsSelfUserMemberUseCase import com.wire.kalium.logic.feature.conversation.ObserveUserListByIdUseCase +import com.wire.kalium.logic.feature.conversation.ObserveUsersTypingUseCase import com.wire.kalium.logic.feature.conversation.RefreshConversationsWithoutMetadataUseCase import com.wire.kalium.logic.feature.conversation.RemoveMemberFromConversationUseCase import com.wire.kalium.logic.feature.conversation.RenameConversationUseCase @@ -235,4 +236,9 @@ class ConversationModule { fun provideObserveArchivedUnreadConversationsCountUseCase( conversationScope: ConversationScope ): ObserveArchivedUnreadConversationsCountUseCase = conversationScope.observeArchivedUnreadConversationsCount + + @ViewModelScoped + @Provides + fun provideObserveUsersTypingUseCase(conversationScope: ConversationScope): ObserveUsersTypingUseCase = + conversationScope.observeUsersTyping } diff --git a/app/src/main/kotlin/com/wire/android/mapper/UIParticipantMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/UIParticipantMapper.kt index db65a78e5af..c9f8ebac107 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/UIParticipantMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/UIParticipantMapper.kt @@ -24,6 +24,7 @@ import com.wire.android.ui.home.conversations.avatar import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant import com.wire.android.ui.home.conversations.previewAsset import com.wire.android.util.ui.WireSessionImageLoader +import com.wire.kalium.logic.data.message.UserSummary import com.wire.kalium.logic.data.message.reaction.MessageReaction import com.wire.kalium.logic.data.message.receipt.DetailedReceipt import com.wire.kalium.logic.data.user.OtherUser @@ -64,7 +65,7 @@ class UIParticipantMapper @Inject constructor( id = userSummary.userId, name = userSummary.userName.orEmpty(), handle = userSummary.userHandle.orEmpty(), - avatarData = previewAsset(wireSessionImageLoader), + avatarData = userSummary.previewAsset(wireSessionImageLoader), membership = userTypeMapper.toMembership(userSummary.userType), unavailable = !userSummary.isUserDeleted && userSummary.userName.orEmpty().isEmpty(), isDeleted = userSummary.isUserDeleted, @@ -79,7 +80,7 @@ class UIParticipantMapper @Inject constructor( id = userSummary.userId, name = userSummary.userName.orEmpty(), handle = userSummary.userHandle.orEmpty(), - avatarData = previewAsset(wireSessionImageLoader), + avatarData = userSummary.previewAsset(wireSessionImageLoader), membership = userTypeMapper.toMembership(userSummary.userType), unavailable = !userSummary.isUserDeleted && userSummary.userName.orEmpty().isEmpty(), isDeleted = userSummary.isUserDeleted, @@ -89,4 +90,18 @@ class UIParticipantMapper @Inject constructor( isProteusVerified = false ) } + + fun toUIParticipant(userSummary: UserSummary): UIParticipant = with(userSummary) { + return UIParticipant( + id = userSummary.userId, + name = userSummary.userName.orEmpty(), + handle = userSummary.userHandle.orEmpty(), + avatarData = previewAsset(wireSessionImageLoader), + membership = userTypeMapper.toMembership(userSummary.userType), + unavailable = !userSummary.isUserDeleted && userSummary.userName.orEmpty().isEmpty(), + isDeleted = userSummary.isUserDeleted, + isSelf = false, + isDefederated = false + ) + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/UserProfileAvatar.kt b/app/src/main/kotlin/com/wire/android/ui/common/UserProfileAvatar.kt index f1d63e2a83c..f4f80c3b2d2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/UserProfileAvatar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/UserProfileAvatar.kt @@ -62,6 +62,7 @@ fun UserProfileAvatar( clickable: Clickable? = null, showPlaceholderIfNoAsset: Boolean = true, withCrossfadeAnimation: Boolean = false, + showStatusIndicator: Boolean = true ) { Box( contentAlignment = Alignment.Center, @@ -82,10 +83,12 @@ fun UserProfileAvatar( .testTag("User avatar"), contentScale = ContentScale.Crop ) - UserStatusIndicator( - status = avatarData.availabilityStatus, - modifier = Modifier.align(Alignment.BottomEnd) - ) + if (showStatusIndicator) { + UserStatusIndicator( + status = avatarData.availabilityStatus, + modifier = Modifier.align(Alignment.BottomEnd) + ) + } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationMemberExt.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationMemberExt.kt index b0a3081303f..cec9d451d0e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationMemberExt.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationMemberExt.kt @@ -24,8 +24,7 @@ import com.wire.android.model.ImageAsset.UserAvatarAsset import com.wire.android.model.UserAvatarData import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.conversation.MemberDetails -import com.wire.kalium.logic.data.message.reaction.MessageReaction -import com.wire.kalium.logic.data.message.receipt.DetailedReceipt +import com.wire.kalium.logic.data.message.UserSummary import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.OtherUser import com.wire.kalium.logic.data.user.SelfUser @@ -70,18 +69,10 @@ val MemberDetails.userType: UserType is SelfUser -> UserType.INTERNAL } -fun MessageReaction.previewAsset( +fun UserSummary.previewAsset( wireSessionImageLoader: WireSessionImageLoader ) = UserAvatarData( - asset = this.userSummary.userPreviewAssetId?.let { UserAvatarAsset(wireSessionImageLoader, it) }, - availabilityStatus = userSummary.availabilityStatus, - connectionState = userSummary.connectionStatus -) - -fun DetailedReceipt.previewAsset( - wireSessionImageLoader: WireSessionImageLoader -) = UserAvatarData( - asset = this.userSummary.userPreviewAssetId?.let { UserAvatarAsset(wireSessionImageLoader, it) }, - availabilityStatus = userSummary.availabilityStatus, - connectionState = userSummary.connectionStatus + asset = this.userPreviewAssetId?.let { UserAvatarAsset(wireSessionImageLoader, it) }, + availabilityStatus = this.availabilityStatus, + connectionState = this.connectionStatus ) 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 42fd1d75507..85917834032 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 @@ -685,8 +685,7 @@ private fun ConversationScreenContent( onClearMentionSearchResult = onClearMentionSearchResult, onSendMessageBundle = onSendMessage, tempWritableVideoUri = tempWritableVideoUri, - tempWritableImageUri = tempWritableImageUri - + tempWritableImageUri = tempWritableImageUri, ) // TODO: uncomment when we have the "scroll to bottom" button implemented diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/UsersTypingIndicator.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/UsersTypingIndicator.kt new file mode 100644 index 00000000000..e172d3fd3b1 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/UsersTypingIndicator.kt @@ -0,0 +1,244 @@ +/* + * Wire + * Copyright (C) 2023 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 + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.InfiniteTransition +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.sebaslogen.resaca.hilt.hiltViewModelScoped +import com.wire.android.R +import com.wire.android.model.UserAvatarData +import com.wire.android.ui.common.UserProfileAvatar +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant +import com.wire.android.ui.home.conversations.typing.TypingIndicatorViewModel +import com.wire.android.ui.home.conversationslist.model.Membership +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.QualifiedID + +const val MAX_PREVIEWS_DISPLAY = 3 + +@Composable +fun UsersTypingIndicatorForConversation( + conversationId: ConversationId, + viewModel: TypingIndicatorViewModel = hiltViewModelScoped(conversationId), +) { + UsersTypingIndicator(usersTyping = viewModel.usersTypingViewState.usersTyping) +} + +@Composable +fun UsersTypingIndicator(usersTyping: List) { + if (usersTyping.isNotEmpty()) { + val rememberTransition = + rememberInfiniteTransition(label = stringResource(R.string.animation_label_typing_indicator_horizontal_transition)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(dimensions().spacing24x) + .background( + color = colorsScheme().surface, + shape = RoundedCornerShape(dimensions().corner14x), + ) + ) { + UsersTypingAvatarPreviews(usersTyping) + Text( + text = pluralStringResource( + R.plurals.typing_indicator_event_message, + usersTyping.size, + usersTyping.first().name, + usersTyping.size - 1 + ), + style = MaterialTheme.wireTypography.label01.copy(color = colorsScheme().secondaryText), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(weight = 1f, fill = false) + .padding( + top = dimensions().spacing4x, + bottom = dimensions().spacing4x, + end = dimensions().spacing8x, + ) + ) + HorizontalBouncingWritingPen(infiniteTransition = rememberTransition) + } + } +} + +@Suppress("MagicNumber") +@Composable +private fun UsersTypingAvatarPreviews(usersTyping: List, maxPreviewsDisplay: Int = MAX_PREVIEWS_DISPLAY) { + usersTyping.take(maxPreviewsDisplay).forEachIndexed { index, user -> + val isSingleUser = usersTyping.size == 1 || maxPreviewsDisplay == 1 + UserProfileAvatar( + avatarData = user.avatarData, + size = dimensions().spacing16x, + padding = dimensions().spacing2x, + showStatusIndicator = false, + modifier = if (isSingleUser) Modifier + else { + Modifier.offset( + x = if (index == 0) dimensions().spacing8x else -(dimensions().spacing6x) + ) + } + ) + } +} + +@Suppress("MagicNumber") +@Composable +private fun HorizontalBouncingWritingPen( + infiniteTransition: InfiniteTransition, +) { + Row(modifier = Modifier.fillMaxHeight()) { + val position by infiniteTransition.animateFloat( + initialValue = -5f, targetValue = -1f, + animationSpec = infiniteRepeatable( + animation = tween(1_000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = infiniteTransition.label + ) + + Icon( + imageVector = Icons.Default.MoreHoriz, + contentDescription = null, + tint = colorsScheme().secondaryText, + modifier = Modifier + .size(dimensions().spacing12x) + .offset(y = -dimensions().spacing2x) + .align(Alignment.Bottom) + ) + Icon( + imageVector = Icons.Default.Edit, + contentDescription = null, + tint = colorsScheme().secondaryText, + modifier = Modifier + .size(dimensions().spacing12x) + .offset(x = position.dp) + .align(Alignment.CenterVertically), + ) + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewUsersTypingOne() { + Column( + modifier = Modifier + .background(color = colorsScheme().background) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + UsersTypingIndicator( + listOf( + UIParticipant( + id = QualifiedID("Alice", "wire.com"), + name = "Alice", + handle = "alice", + isSelf = false, + isService = false, + avatarData = UserAvatarData(), + membership = Membership.None, + connectionState = null, + unavailable = false, + isDeleted = false, + readReceiptDate = null, + botService = null, + isDefederated = false + ) + ) + ) + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewUsersTypingMoreThanOne() { + Column( + modifier = Modifier + .background(color = colorsScheme().background) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + UsersTypingIndicator( + listOf( + UIParticipant( + id = QualifiedID("Bob", "wire.com"), + name = "Bob", + handle = "bob", + isSelf = false, + isService = false, + avatarData = UserAvatarData(), + membership = Membership.None, + connectionState = null, + unavailable = false, + isDeleted = false, + readReceiptDate = null, + botService = null, + isDefederated = false + ), + UIParticipant( + id = QualifiedID("alice", "wire.com"), + name = "Alice Smith", + handle = "alice", + isSelf = false, + isService = false, + avatarData = UserAvatarData(), + membership = Membership.None, + connectionState = null, + unavailable = false, + isDeleted = false, + readReceiptDate = null, + botService = null, + isDefederated = false + ) + ) + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModel.kt new file mode 100644 index 00000000000..f29725673f7 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModel.kt @@ -0,0 +1,59 @@ +/* + * Wire + * Copyright (C) 2023 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.typing + +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.appLogger +import com.wire.android.ui.home.conversations.ConversationNavArgs +import com.wire.android.ui.home.conversations.usecase.ObserveUsersTypingInConversationUseCase +import com.wire.android.ui.navArgs +import com.wire.kalium.logic.data.id.QualifiedID +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class TypingIndicatorViewModel @Inject constructor( + private val observeUsersTypingInConversation: ObserveUsersTypingInConversationUseCase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() + val conversationId: QualifiedID = conversationNavArgs.conversationId + + var usersTypingViewState by mutableStateOf(UsersTypingViewState()) + private set + + init { + observeUsersTypingState() + } + + private fun observeUsersTypingState() { + viewModelScope.launch { + observeUsersTypingInConversation(conversationId).collect { + appLogger.d("Users typing: $it") + usersTypingViewState = usersTypingViewState.copy(usersTyping = it) + } + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/UsersTypingViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/UsersTypingViewState.kt new file mode 100644 index 00000000000..47144155f15 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/UsersTypingViewState.kt @@ -0,0 +1,24 @@ +/* + * Wire + * Copyright (C) 2023 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.typing + +import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant + +data class UsersTypingViewState( + val usersTyping: List = emptyList() +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveUsersTypingInConversationUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveUsersTypingInConversationUseCase.kt new file mode 100644 index 00000000000..00d7a810809 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveUsersTypingInConversationUseCase.kt @@ -0,0 +1,36 @@ +/* + * Wire + * Copyright (C) 2023 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.UIParticipantMapper +import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.feature.conversation.ObserveUsersTypingUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class ObserveUsersTypingInConversationUseCase @Inject constructor( + private val observeUsersTyping: ObserveUsersTypingUseCase, + private val uiParticipantMapper: UIParticipantMapper +) { + + suspend operator fun invoke(conversationId: ConversationId): Flow> = + observeUsersTyping(conversationId) + .map { it.map { userSummary -> uiParticipantMapper.toUIParticipant(userSummary) } } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt index 0051be6f42e..e12f0be78a8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt @@ -41,6 +41,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity @@ -48,6 +49,7 @@ import androidx.compose.ui.unit.dp import com.wire.android.ui.common.banner.SecurityClassificationBannerForConversation import com.wire.android.ui.common.bottombar.BottomNavigationBarHeight import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.home.conversations.UsersTypingIndicatorForConversation import com.wire.android.ui.home.conversations.model.UriAsset import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSelectItem import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSubMenuState @@ -141,6 +143,15 @@ fun EnabledMessageComposer( ) } + Column( + modifier = Modifier + .background(color = colorsScheme().backgroundVariant) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + UsersTypingIndicatorForConversation(conversationId = conversationId) + } + if (additionalOptionStateHolder.additionalOptionsSubMenuState != AdditionalOptionSubMenuState.RecordAudio) { Box(fillRemainingSpaceOrWrapContent) { var currentSelectedLineIndex by remember { mutableStateOf(0) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt index 9d8d1816aab..c4853552216 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt @@ -131,7 +131,7 @@ fun PrivacySettingsScreenContent( title = stringResource(R.string.settings_show_typing_indicator_title), switchState = SwitchState.Enabled(value = isTypingIndicatorEnabled, onCheckedChange = setTypingIndicatorState), arrowType = ArrowType.NONE, - subtitle = stringResource(id = R.string.settings_send_read_receipts_description) + subtitle = stringResource(id = R.string.settings_show_typing_indicator_description) ) AppLockItem( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3f3e9119bc3..275fdfa3e8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -676,6 +676,11 @@ "Sent a message" "Someone sent a message" + + + %s is typing + %1$s and %2$d more are typing + CONTACTS New Group @@ -727,6 +732,7 @@ Conversation could not be unarchived MessageComposeInputState transition + HorizontalBouncingWritingPen transition Collapse button rotation degree transition Open Conversation Email diff --git a/app/src/test/kotlin/com/wire/android/mapper/UIParticipantMapperTest.kt b/app/src/test/kotlin/com/wire/android/mapper/UIParticipantMapperTest.kt index f82fd4ca7bf..451504821ce 100644 --- a/app/src/test/kotlin/com/wire/android/mapper/UIParticipantMapperTest.kt +++ b/app/src/test/kotlin/com/wire/android/mapper/UIParticipantMapperTest.kt @@ -30,6 +30,7 @@ import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.conversation.Conversation.Member import com.wire.kalium.logic.data.conversation.MemberDetails import com.wire.kalium.logic.data.id.TeamId +import com.wire.kalium.logic.data.message.UserSummary import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.OtherUser import com.wire.kalium.logic.data.user.SelfUser @@ -39,6 +40,7 @@ import com.wire.kalium.logic.data.user.type.UserType import io.mockk.MockKAnnotations import io.mockk.impl.annotations.MockK import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.Test class UIParticipantMapperTest { @@ -62,6 +64,38 @@ class UIParticipantMapperTest { } } + @Test + fun givenUserSummary_whenMappingUiParticipant_thenCorrectValuesShouldBeReturned() = runTest { + // Given + val (_, mapper) = Arrangement().arrange() + val data: List = listOf( + UserSummary( + testOtherUser(0).id, + testOtherUser(0).name, + testOtherUser(0).handle, + testOtherUser(0).previewPicture, + testOtherUser(0).userType, + testOtherUser(0).deleted, + testOtherUser(0).connectionStatus, + testOtherUser(0).availabilityStatus + ) + ) + // When + val results: List = data.map { mapper.toUIParticipant(it) } + + // Then + assertEquals(testUIParticipant(0).id, results.first().id) + assertEquals(testUIParticipant(0).name, results.first().name) + assertEquals(testUIParticipant(0).handle, results.first().handle) + assertEquals(testUIParticipant(0).avatarData.asset, results.first().avatarData.asset) + assertEquals(null, results.first().botService) + assertEquals(null, results.first().connectionState) + assertEquals(false, results.first().isDefederated) + assertEquals(false, results.first().isSelf) + assertEquals(false, results.first().unavailable) + assertEquals(false, results.first().isService) + } + private fun compareResult( wireSessionImageLoader: WireSessionImageLoader, memberDetails: MemberDetails, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt index b9f4185bf5f..b7cbad4b3e6 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt @@ -43,6 +43,7 @@ import com.wire.kalium.logic.feature.conversation.GetConversationUnreadEventsCou import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase import com.wire.kalium.logic.feature.message.ToggleReactionUseCase +import com.wire.kalium.logic.feature.sessionreset.ResetSessionResult import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase import com.wire.kalium.logic.functional.Either import io.mockk.MockKAnnotations @@ -164,6 +165,10 @@ class ConversationMessagesViewModelArrangement { messagesChannel.send(pagingDataFlow) } + suspend fun withResetSessionResult(resetSessionResult: ResetSessionResult = ResetSessionResult.Success) = apply { + coEvery { resetSession(any(), any(), any()) } returns resetSessionResult + } + fun withSuccessfulSaveAssetMessage( assetMimeType: String, assetName: String, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt index d7fa9652804..42cbff0814e 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt @@ -24,9 +24,9 @@ import androidx.paging.PagingData import androidx.paging.map import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.NavigationTestExtension import com.wire.android.framework.TestMessage import com.wire.android.framework.TestMessage.GENERIC_ASSET_CONTENT -import com.wire.android.config.NavigationTestExtension import com.wire.android.ui.home.conversations.mockUITextMessage import com.wire.kalium.logic.StorageFailure import com.wire.kalium.logic.data.message.MessageContent @@ -39,7 +39,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import okio.Path.Companion.toPath import org.amshove.kluent.shouldBeEqualTo -import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -176,11 +175,12 @@ class ConversationMessagesViewModelTest { } @Test - @Disabled fun `given a message with failed decryption, when resetting the session, then should call ResetSessionUseCase`() = runTest { val userId = UserId("someID", "someDomain") val clientId = "someClientId" val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() + .withObservableAudioMessagesState(flowOf()) + .withResetSessionResult() .arrange() viewModel.onResetSession(userId, clientId) diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModelTest.kt new file mode 100644 index 00000000000..32e7a60e083 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModelTest.kt @@ -0,0 +1,96 @@ +/* + * Wire + * Copyright (C) 2023 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.typing + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.NavigationTestExtension +import com.wire.android.framework.TestConversation +import com.wire.android.ui.home.conversations.ConversationNavArgs +import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant +import com.wire.android.ui.home.conversations.typing.TypingIndicatorViewModelTest.Arrangement.Companion.expectedUIParticipant +import com.wire.android.ui.home.conversations.usecase.ObserveUsersTypingInConversationUseCase +import com.wire.android.ui.navArgs +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.user.UserId +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.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class) +@ExtendWith(NavigationTestExtension::class) +class TypingIndicatorViewModelTest { + + @Test + fun `given a conversation, when start observing, then call the right use case for observation`() = runTest { + // Given + val (arrangement, _) = Arrangement() + .withParticipantsTyping(listOf(expectedUIParticipant)) + .arrange() + + // Then + coVerify { arrangement.observeUsersTypingInConversation(TestConversation.ID) } + arrangement.observeUsersTypingInConversation(TestConversation.ID).test { + val participants = awaitItem() + assertEquals(expectedUIParticipant, participants.first()) + awaitComplete() + } + } + + private class Arrangement { + + @MockK + lateinit var observeUsersTypingInConversation: ObserveUsersTypingInConversationUseCase + + @MockK + lateinit var savedStateHandle: SavedStateHandle + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { savedStateHandle.navArgs() } returns TestConversation.ID + every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId = TestConversation.ID) + coEvery { observeUsersTypingInConversation(eq(TestConversation.ID)) } returns flowOf(emptyList()) + } + + fun withParticipantsTyping(usersTyping: List = emptyList()) = apply { + coEvery { observeUsersTypingInConversation(eq(TestConversation.ID)) } returns flowOf(usersTyping) + } + + fun arrange() = this to TypingIndicatorViewModel(observeUsersTypingInConversation, savedStateHandle) + + companion object { + val expectedUIParticipant = UIParticipant( + id = UserId("id", "domain"), + name = "name", + handle = "handle", + isSelf = false, + isService = false + ) + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveUsersTypingInConversationUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveUsersTypingInConversationUseCaseTest.kt new file mode 100644 index 00000000000..2448d71c2b7 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveUsersTypingInConversationUseCaseTest.kt @@ -0,0 +1,107 @@ +/* + * Wire + * Copyright (C) 2023 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.config.CoroutineTestExtension +import com.wire.android.mapper.UIParticipantMapper +import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant +import com.wire.android.ui.home.conversations.usecase.ObserveUsersTypingInConversationUseCaseTest.Arrangement.Companion.expectedUIParticipant +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.message.UserSummary +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.UserType +import com.wire.kalium.logic.feature.conversation.ObserveUsersTypingUseCase +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.flow.first +import kotlinx.coroutines.flow.flowOf +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) +class ObserveUsersTypingInConversationUseCaseTest { + + @Test + fun given_UsersAreTyping_ThenObserveFlowAndReturnUIParticipant() = runTest { + val userSummaryArg = UserSummary( + expectedUIParticipant.id, + expectedUIParticipant.name, + expectedUIParticipant.handle, + null, + UserType.NONE, + false, + ConnectionState.ACCEPTED, + UserAvailabilityStatus.AVAILABLE + ) + val (arrangement, useCase) = Arrangement() + .withMapperFrom(userSummaryArg) + .withObserveUsersTypingResult(setOf(userSummaryArg)) + .arrange() + + val result = useCase(ConversationId("id", "domain")) + + assertTrue(result.first().isNotEmpty()) + assertEquals(expectedUIParticipant, result.first().first()) + coVerify(exactly = 1) { arrangement.observeUsersTyping(any()) } + } + + private class Arrangement { + + @MockK + lateinit var observeUsersTyping: ObserveUsersTypingUseCase + + @MockK + lateinit var uiParticipantMapper: UIParticipantMapper + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + } + + fun withObserveUsersTypingResult(usersTyping: Set) = apply { + coEvery { observeUsersTyping(any()) } returns flowOf(usersTyping) + } + + fun withMapperFrom(userSummary: UserSummary) = apply { + every { uiParticipantMapper.toUIParticipant(eq(userSummary)) } returns expectedUIParticipant + } + + fun arrange() = this to ObserveUsersTypingInConversationUseCase( + observeUsersTyping, uiParticipantMapper + ) + + companion object { + val expectedUIParticipant = UIParticipant( + id = UserId("id", "domain"), + name = "name", + handle = "handle", + isSelf = false, + isService = false + ) + } + } +} diff --git a/build-logic/plugins/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/plugins/src/main/kotlin/AndroidApplicationConventionPlugin.kt index f4d33d34472..8da78bb53a9 100644 --- a/build-logic/plugins/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/plugins/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -54,6 +54,7 @@ class AndroidApplicationConventionPlugin : Plugin { excludes.add("META-INF/ASL2.0") excludes.add("META-INF/NOTICE") excludes.add("META-INF/licenses/ASM") + excludes.add("META-INF/versions/9/previous-compilation-data.bin") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ce98e0189c..53ececb3d29 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ kotlin = "1.9.0" # KotlinX coroutines = "1.7.3" -ktx-dateTime = "0.4.0" +ktx-dateTime = "0.4.1" ktx-immutableCollections = "0.3.5" ktx-serialization = "1.5.1"