diff --git a/.github/workflows/build-edge-env.yml b/.github/workflows/build-edge-env.yml index 6d1de717470..3b091321437 100644 --- a/.github/workflows/build-edge-env.yml +++ b/.github/workflows/build-edge-env.yml @@ -95,4 +95,5 @@ jobs: serviceAccountJson: service_account.json packageName: com.wire.internal releaseFiles: app/build/outputs/bundle/internalCompat/*.aab - track: alpha + track: production + status: completed diff --git a/.github/workflows/deploy-adr-docs.yml b/.github/workflows/deploy-adr-docs.yml index c854eda6b69..f5c1bd2d141 100644 --- a/.github/workflows/deploy-adr-docs.yml +++ b/.github/workflows/deploy-adr-docs.yml @@ -33,7 +33,7 @@ jobs: - name: Deploy docs 🚀 if: github.event_name == 'push' && github.ref_name == 'develop' - uses: JamesIves/github-pages-deploy-action@v4.6.8 + uses: JamesIves/github-pages-deploy-action@v4.6.9 with: branch: gh-pages clean: false diff --git a/.github/workflows/publish-test-results.yml b/.github/workflows/publish-test-results.yml index c8ecbf005f9..f1291ea7a59 100644 --- a/.github/workflows/publish-test-results.yml +++ b/.github/workflows/publish-test-results.yml @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@v4 - name: Download and Extract Artifacts - uses: dawidd6/action-download-artifact@v4 + uses: dawidd6/action-download-artifact@v6 with: run_id: ${{ github.event.workflow_run.id }} path: artifacts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000000..18bba2730fa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing + +## I want to contribute to Wire + +You can contribute to Wire in several ways: + +## Finding bugs + +If you find a bug in how Wire apps work, please submit a ticket to [our support](https://support.wire.com) and we will keep you informed about the progress. +Alternatively, you can submit your finding on [wire issues](https://github.com/wireapp/wire/issues). Please make sure to provide as much information as possible to help us reproduce the issue. + +## Contributing to the code + +If you wish to contribute source code to one of our repositories you have to sign +our [Contributor Agreement](https://github.com/wireapp/wire/raw/master/assets/Wire%20Contributor%20Agreement.pdf) +first. + +When you submit your first pull request, you can sign the agreement electronically by filling in the +required information. You will not have to sign it again for subsequent pull requests from the same +GitHub account. + +When opening a pull request, please make sure to follow the next guidelines: + +- Make sure to fill in the pull request template to the fullest extent possible, this will help us + understand faster the changes proposed. +- Make sure to run the tests and linters before submitting the pull request. +- Add the necessary tests for the changes you are proposing, this will help us ensure that the + changes are working as expected. + +> [!NOTE] +> We accept only bug fixes and code improvements. We cannot accept new features, UI or UX changes – these are decided by Wire and built by the Wire development team. + +## I want to help translate Wire + +If you want to help Wire to speak more languages, please refer to our [site](https://support.wire.com/hc/en-us/articles/202856874-Language-support), to see the official list of supported languages and those who are open to contribute. + +To do so, you will find instructions there, but you can do the following: + +1. Create a [Crowdin account](https://crowdin.com/). +2. Request access to add translations in our [project](https://crowdin.com/project/wire-android-reloaded). +3. Translate away. diff --git a/README.md b/README.md index 6bf98626d1e..503d525dfeb 100644 --- a/README.md +++ b/README.md @@ -61,16 +61,6 @@ It might be that after cloning the Android project, some build issues appear on - There is a valid SDK path on your `local.properties` AND `kalium/local.properties` files pointing to the Android SDK folder. In Mac, that folder can be usually found under `sdk.dir=/Users/YOUR_USER_FOLDER/Library/Android/sdk`. The IDE **will not** create `kalium/local.properties` automatically, so you might want to copy/paste the one in the project root - When you've already started working on the project adding some commits, it might occur that your local build breaks, if that is the case, make sure you've updated the `kalium` submodule reference by running: `git submodule update --remote --merge` -## Contributing - -If you want to help Wire to speak more languages, please refer to our [site](https://support.wire.com/hc/en-us/articles/202856874-Language-support), to see the official list of supported languages and those who are open to contribute. - -To do so, you will find instructions there, but you can do the following: - -1. Create a [Crowdin account](https://crowdin.com/). -2. Request access to add translations in our [project](https://crowdin.com/project/wire-android-reloaded). -3. Translate away. - # App flavours We have a few different app flavours with different intended usages. Each app flavour has a different icon background colour to enable easier distinction. @@ -99,3 +89,7 @@ To see how they are customised in details, check [the flavour configuration file ## Build Types The apps can be built for release or debugging. Debug versions might have extra debugging tools, are not minified, and can be profiled if needed. In general, debug builds _run slower_ due to the lack of minimisation. + +## Contributing + +If you want to contribute to Wire for Android, please refer to the [CONTRIBUTING.md](./CONTRIBUTING.md) file for more information. diff --git a/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt b/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt index 0b32e3d76f0..3bdf323e37e 100644 --- a/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt @@ -492,4 +492,12 @@ class UseCaseModule { @Provides fun provideSendFCMTokenToAPIUseCase(@KaliumCoreLogic coreLogic: CoreLogic, @CurrentAccount currentAccount: UserId) = coreLogic.getSessionScope(currentAccount).debug.sendFCMTokenToServer + + @ViewModelScoped + @Provides + fun provideMigrateFromPersonalToTeamUseCase( + @KaliumCoreLogic coreLogic: CoreLogic, + @CurrentAccount currentAccount: UserId + ) = + coreLogic.getSessionScope(currentAccount).migrateFromPersonalToTeam } 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 baf45e7b76b..f726d098dc4 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 @@ -327,4 +327,14 @@ class ConversationModule { @Provides fun provideGetFavoriteFolderUseCase(conversationScope: ConversationScope) = conversationScope.getFavoriteFolder + + @ViewModelScoped + @Provides + fun provideAddConversationToFavoritesUseCase(conversationScope: ConversationScope) = + conversationScope.addConversationToFavorites + + @ViewModelScoped + @Provides + fun provideRemoveConversationFromFavoritesUseCase(conversationScope: ConversationScope) = + conversationScope.removeConversationFromFavorites } diff --git a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt index fb7ca3929a0..99820e48b3e 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt @@ -34,6 +34,7 @@ import com.wire.kalium.logic.data.conversation.ConversationDetails.Self import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents import com.wire.kalium.logic.data.conversation.MutedConversationStatus import com.wire.kalium.logic.data.conversation.UnreadEventCount +import com.wire.kalium.logic.data.id.TeamId import com.wire.kalium.logic.data.message.UnreadEventType import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.UserAvailabilityStatus @@ -43,20 +44,21 @@ fun ConversationDetailsWithEvents.toConversationItem( wireSessionImageLoader: WireSessionImageLoader, userTypeMapper: UserTypeMapper, searchQuery: String, + selfUserTeamId: TeamId? ): ConversationItem = when (val conversationDetails = this.conversationDetails) { is Group -> { ConversationItem.GroupConversation( groupName = conversationDetails.conversation.name.orEmpty(), conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, - isLegalHold = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), + showLegalHoldIndicator = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), lastMessageContent = lastMessage.toUIPreview(unreadEventCount), badgeEventType = parseConversationEventType( mutedStatus = conversationDetails.conversation.mutedStatus, unreadEventCount = unreadEventCount ), hasOnGoingCall = conversationDetails.hasOngoingCall && conversationDetails.isSelfUserMember, - isSelfUserCreator = conversationDetails.isSelfUserCreator, + isFromTheSameTeam = conversationDetails.conversation.teamId == selfUserTeamId, isSelfUserMember = conversationDetails.isSelfUserMember, teamId = conversationDetails.conversation.teamId, selfMemberRole = conversationDetails.selfRole, @@ -65,6 +67,7 @@ fun ConversationDetailsWithEvents.toConversationItem( proteusVerificationStatus = conversationDetails.conversation.proteusVerificationStatus, hasNewActivitiesToShow = hasNewActivitiesToShow, searchQuery = searchQuery, + isFavorite = conversationDetails.isFavorite ) } @@ -83,7 +86,7 @@ fun ConversationDetailsWithEvents.toConversationItem( ), conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, - isLegalHold = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), + showLegalHoldIndicator = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), lastMessageContent = lastMessage.toUIPreview(unreadEventCount), badgeEventType = parsePrivateConversationEventType( conversationDetails.otherUser.connectionStatus, @@ -101,6 +104,7 @@ fun ConversationDetailsWithEvents.toConversationItem( proteusVerificationStatus = conversationDetails.conversation.proteusVerificationStatus, hasNewActivitiesToShow = hasNewActivitiesToShow, searchQuery = searchQuery, + isFavorite = conversationDetails.isFavorite ) } 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 24bb43d7d2f..cb0680e6e33 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt @@ -148,7 +148,7 @@ class MessageMapper @Inject constructor( is SelfUser, null -> Membership.None }, connectionState = getConnectionState(sender), - isLegalHold = sender?.isUnderLegalHold == true, + showLegalHoldIndicator = sender?.isUnderLegalHold == true, messageTime = MessageTime(message.date), messageStatus = getMessageStatus(message), messageId = message.id, diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt index 8fb90a99f6b..3f6c08ed1be 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt @@ -21,9 +21,11 @@ import android.content.Context import android.media.MediaPlayer import android.media.MediaPlayer.SEEK_CLOSEST_SYNC import android.net.Uri +import com.wire.android.di.KaliumCoreLogic +import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.logic.feature.asset.GetMessageAssetUseCase import com.wire.kalium.logic.feature.asset.MessageAssetResult +import com.wire.kalium.logic.feature.session.CurrentSessionResult import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -34,14 +36,44 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import javax.inject.Inject +import javax.inject.Singleton -class ConversationAudioMessagePlayer +@Singleton +class ConversationAudioMessagePlayerProvider @Inject constructor( private val context: Context, private val audioMediaPlayer: MediaPlayer, - private val getMessageAsset: GetMessageAssetUseCase + @KaliumCoreLogic private val coreLogic: CoreLogic, +) { + private var player: ConversationAudioMessagePlayer? = null + private var usageCount: Int = 0 + + @Synchronized + fun provide(): ConversationAudioMessagePlayer { + val player = player ?: ConversationAudioMessagePlayer(context, audioMediaPlayer, coreLogic).also { player = it } + usageCount++ + + return player + } + + @Synchronized + fun onCleared() { + usageCount-- + if (usageCount <= 0) { + player?.close() + player = null + } + } +} + +class ConversationAudioMessagePlayer +internal constructor( + private val context: Context, + private val audioMediaPlayer: MediaPlayer, + @KaliumCoreLogic private val coreLogic: CoreLogic, ) { private companion object { const val UPDATE_POSITION_INTERVAL_IN_MS = 1000L @@ -137,7 +169,7 @@ class ConversationAudioMessagePlayer } audioMessageStateHistory - } + }.onStart { emit(audioMessageStateHistory) } private var currentAudioMessageId: String? = null @@ -169,10 +201,10 @@ class ConversationAudioMessagePlayer } private suspend fun stopCurrentlyPlayingAudioMessage() { - if (currentAudioMessageId != null) { - val currentAudioState = audioMessageStateHistory[currentAudioMessageId] + currentAudioMessageId?.let { + val currentAudioState = audioMessageStateHistory[it] if (currentAudioState?.audioMediaPlayingState != AudioMediaPlayingState.Fetching) { - stop(currentAudioMessageId!!) + stop(it) } } } @@ -194,6 +226,9 @@ class ConversationAudioMessagePlayer coroutineScope { launch { + val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() + if (currentAccountResult is CurrentSessionResult.Failure) return@launch + audioMessageStateUpdate.emit( AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( messageId, @@ -201,7 +236,12 @@ class ConversationAudioMessagePlayer ) ) - when (val result = getMessageAsset(conversationId, messageId).await()) { + val assetMessage = coreLogic + .getSessionScope((currentAccountResult as CurrentSessionResult.Success).accountInfo.userId) + .messages + .getAssetMessage(conversationId, messageId) + + when (val result = assetMessage.await()) { is MessageAssetResult.Success -> { audioMessageStateUpdate.emit( AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( @@ -219,9 +259,7 @@ class ConversationAudioMessagePlayer ) audioMediaPlayer.prepare() - if (position != null) { - audioMediaPlayer.seekTo(position) - } + if (position != null) audioMediaPlayer.seekTo(position) audioMediaPlayer.start() @@ -292,7 +330,7 @@ class ConversationAudioMessagePlayer ) } - fun close() { - audioMediaPlayer.release() + internal fun close() { + audioMediaPlayer.reset() } } diff --git a/app/src/main/kotlin/com/wire/android/model/SnackBarMessage.kt b/app/src/main/kotlin/com/wire/android/model/SnackBarMessage.kt index 8a020d78792..a3fb5b97001 100644 --- a/app/src/main/kotlin/com/wire/android/model/SnackBarMessage.kt +++ b/app/src/main/kotlin/com/wire/android/model/SnackBarMessage.kt @@ -27,3 +27,10 @@ interface SnackBarMessage { val uiText: UIText val actionLabel: UIText? get() = null } + +data class DefaultSnackBarMessage( + override val uiText: UIText, + override val actionLabel: UIText? = null +) : SnackBarMessage + +fun UIText.asSnackBarMessage(actionLabel: UIText? = null): SnackBarMessage = DefaultSnackBarMessage(this, actionLabel) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt index f9877a1fa6d..ec577f2c221 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt @@ -177,6 +177,8 @@ fun OngoingCallScreen( clearVideoPreview = sharedCallingViewModel::clearVideoPreview, onCollapse = onCollapse, requestVideoStreams = ongoingCallViewModel::requestVideoStreams, + onSelectedParticipant = ongoingCallViewModel::onSelectedParticipant, + selectedParticipantForFullScreen = ongoingCallViewModel.selectedParticipant, hideDoubleTapToast = ongoingCallViewModel::hideDoubleTapToast, onCameraPermissionPermanentlyDenied = onCameraPermissionPermanentlyDenied, participants = sharedCallingViewModel.participantsState, @@ -289,6 +291,8 @@ private fun OngoingCallContent( hideDoubleTapToast: () -> Unit, onCameraPermissionPermanentlyDenied: () -> Unit, requestVideoStreams: (participants: List) -> Unit, + onSelectedParticipant: (selectedParticipant: SelectedParticipant) -> Unit, + selectedParticipantForFullScreen: SelectedParticipant, participants: PersistentList, inPictureInPictureMode: Boolean, currentUserId: UserId, @@ -303,7 +307,6 @@ private fun OngoingCallContent( ) var shouldOpenFullScreen by remember { mutableStateOf(false) } - var selectedParticipantForFullScreen by remember { mutableStateOf(SelectedParticipant()) } WireBottomSheetScaffold( sheetDragHandle = null, @@ -391,11 +394,14 @@ private fun OngoingCallContent( selectedParticipant = selectedParticipantForFullScreen, height = this@BoxWithConstraints.maxHeight - dimensions().spacing4x, closeFullScreen = { + onSelectedParticipant(SelectedParticipant()) shouldOpenFullScreen = !shouldOpenFullScreen }, onBackButtonClicked = { + onSelectedParticipant(SelectedParticipant()) shouldOpenFullScreen = !shouldOpenFullScreen }, + requestVideoStreams = requestVideoStreams, setVideoPreview = setVideoPreview, clearVideoPreview = clearVideoPreview, participants = participants @@ -412,7 +418,7 @@ private fun OngoingCallContent( requestVideoStreams = requestVideoStreams, currentUserId = currentUserId, onDoubleTap = { selectedParticipant -> - selectedParticipantForFullScreen = selectedParticipant + onSelectedParticipant(selectedParticipant) shouldOpenFullScreen = !shouldOpenFullScreen }, ) @@ -580,6 +586,8 @@ fun PreviewOngoingCallContent(participants: PersistentList) { participants = participants, inPictureInPictureMode = false, currentUserId = UserId("userId", "domain"), + onSelectedParticipant = {}, + selectedParticipantForFullScreen = SelectedParticipant(), ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt index 8e07e40bc1b..84c6e1a57d1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt @@ -28,8 +28,10 @@ import com.wire.android.appLogger import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.CurrentAccount import com.wire.android.ui.calling.model.UICallParticipant +import com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallClient +import com.wire.kalium.logic.data.call.CallQuality import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId @@ -63,6 +65,8 @@ class OngoingCallViewModel @AssistedInject constructor( var state by mutableStateOf(OngoingCallState()) private set + var selectedParticipant by mutableStateOf(SelectedParticipant()) + private set init { viewModelScope.launch { @@ -124,7 +128,11 @@ class OngoingCallViewModel @AssistedInject constructor( .also { if (it.isNotEmpty()) { val clients: List = it.map { uiParticipant -> - CallClient(uiParticipant.id.toString(), uiParticipant.clientId) + CallClient( + userId = uiParticipant.id.toString(), + clientId = uiParticipant.clientId, + quality = mapQualityStream(uiParticipant) + ) } requestVideoStreams(conversationId, clients) } @@ -132,12 +140,20 @@ class OngoingCallViewModel @AssistedInject constructor( } } + private fun mapQualityStream(uiParticipant: UICallParticipant): CallQuality { + return if (uiParticipant.clientId == selectedParticipant.clientId) { + CallQuality.HIGH + } else { + CallQuality.LOW + } + } + private fun startDoubleTapToastDisplayCountDown() { doubleTapIndicatorCountDownTimer?.cancel() doubleTapIndicatorCountDownTimer = object : CountDownTimer(DOUBLE_TAP_TOAST_DISPLAY_TIME, COUNT_DOWN_INTERVAL) { override fun onTick(p0: Long) { - appLogger.i("startDoubleTapToastDisplayCountDown: $p0") + appLogger.d("$TAG - startDoubleTapToastDisplayCountDown: $p0") } override fun onFinish() { @@ -171,10 +187,16 @@ class OngoingCallViewModel @AssistedInject constructor( } } + fun onSelectedParticipant(selectedParticipant: SelectedParticipant) { + appLogger.d("$TAG - Selected participant: ${selectedParticipant.toLogString()}") + this.selectedParticipant = selectedParticipant + } + companion object { const val DOUBLE_TAP_TOAST_DISPLAY_TIME = 7000L const val COUNT_DOWN_INTERVAL = 1000L const val DELAY_TO_SHOW_DOUBLE_TAP_TOAST = 500L + const val TAG = "OngoingCallViewModel" } @AssistedFactory diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt index 7edbd0d7ba3..cee64f83032 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt @@ -60,6 +60,7 @@ fun FullScreenTile( closeFullScreen: (offset: Offset) -> Unit, onBackButtonClicked: () -> Unit, setVideoPreview: (View) -> Unit, + requestVideoStreams: (participants: List) -> Unit, clearVideoPreview: () -> Unit, modifier: Modifier = Modifier, contentPadding: Dp = dimensions().spacing4x, @@ -119,6 +120,10 @@ fun FullScreenTile( } ) } + + LaunchedEffect(selectedParticipant.userId) { + requestVideoStreams(listOf(it)) + } } } @@ -139,6 +144,7 @@ fun PreviewFullScreenTile() = WireTheme { closeFullScreen = {}, onBackButtonClicked = {}, setVideoPreview = {}, + requestVideoStreams = {}, clearVideoPreview = {}, participants = participants, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt index 95238a33c27..af1a1d87f45 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt @@ -17,10 +17,16 @@ */ package com.wire.android.ui.calling.ongoing.fullscreen +import com.wire.kalium.logger.obfuscateId import com.wire.kalium.logic.data.user.UserId data class SelectedParticipant( val userId: UserId = UserId("", ""), val clientId: String = "", val isSelfUser: Boolean = false -) +) { + + fun toLogString(): String { + return "SelectedParticipant(userId=${userId.toLogString()}, clientId=${clientId.obfuscateId()}, isSelfUser=$isSelfUser)" + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt index 57007fae7aa..4d43febe51e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt @@ -37,7 +37,7 @@ import com.wire.kalium.logic.data.user.UserId fun ConversationSheetContent( conversationSheetState: ConversationSheetState, onMutingConversationStatusChange: () -> Unit, - addConversationToFavourites: () -> Unit, + changeFavoriteState: (GroupDialogState, addToFavorite: Boolean) -> Unit, moveConversationToFolder: () -> Unit, updateConversationArchiveStatus: (DialogState) -> Unit, clearConversationContent: (DialogState) -> Unit, @@ -54,9 +54,8 @@ fun ConversationSheetContent( ConversationOptionNavigation.Home -> { ConversationMainSheetContent( conversationSheetContent = conversationSheetState.conversationSheetContent!!, + changeFavoriteState = changeFavoriteState, // TODO(profile): enable when implemented -// -// addConversationToFavourites = addConversationToFavourites, // moveConversationToFolder = moveConversationToFolder, updateConversationArchiveStatus = updateConversationArchiveStatus, clearConversationContent = clearConversationContent, @@ -100,7 +99,7 @@ sealed class ConversationOptionNavigation { } sealed class ConversationTypeDetail { - data class Group(val conversationId: ConversationId, val isCreator: Boolean) : ConversationTypeDetail() + data class Group(val conversationId: ConversationId, val isFromTheSameTeam: Boolean) : ConversationTypeDetail() data class Private( val avatarAsset: UserAvatarAsset?, val userId: UserId, @@ -125,6 +124,7 @@ data class ConversationSheetContent( val mlsVerificationStatus: Conversation.VerificationStatus, val proteusVerificationStatus: Conversation.VerificationStatus, val isUnderLegalHold: Boolean, + val isFavorite: Boolean? ) { private val isSelfUserMember: Boolean get() = selfRole != null @@ -134,10 +134,11 @@ data class ConversationSheetContent( && (conversationTypeDetail.blockingState != BlockingState.BLOCKED)) || conversationTypeDetail is ConversationTypeDetail.Group) - fun canDeleteGroup(): Boolean = - conversationTypeDetail is ConversationTypeDetail.Group && + fun canDeleteGroup(): Boolean { + return conversationTypeDetail is ConversationTypeDetail.Group && selfRole == Conversation.Member.Role.Admin && - conversationTypeDetail.isCreator && isTeamConversation + conversationTypeDetail.isFromTheSameTeam && isTeamConversation + } fun canLeaveTheGroup(): Boolean = conversationTypeDetail is ConversationTypeDetail.Group && isSelfUserMember @@ -147,9 +148,9 @@ data class ConversationSheetContent( fun canUnblockUser(): Boolean = conversationTypeDetail is ConversationTypeDetail.Private && conversationTypeDetail.blockingState == BlockingState.BLOCKED - fun canAddToFavourite(): Boolean = - (conversationTypeDetail is ConversationTypeDetail.Private && conversationTypeDetail.blockingState != BlockingState.BLOCKED) - || conversationTypeDetail is ConversationTypeDetail.Group + fun canAddToFavourite(): Boolean = isFavorite != null && + ((conversationTypeDetail is ConversationTypeDetail.Private && conversationTypeDetail.blockingState != BlockingState.BLOCKED) + || conversationTypeDetail is ConversationTypeDetail.Group) fun isAbandonedOneOnOneConversation(participantsCount: Int): Boolean = title.isEmpty() && participantsCount == 1 } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt index 0e48a9ac9a7..f5bd110bc79 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt @@ -70,7 +70,7 @@ fun rememberConversationSheetState( mutingConversationState = mutedStatus, conversationTypeDetail = ConversationTypeDetail.Group( conversationId = conversationId, - isCreator = isSelfUserCreator + isFromTheSameTeam = isFromTheSameTeam ), isTeamConversation = teamId != null, selfRole = selfMemberRole, @@ -78,7 +78,8 @@ fun rememberConversationSheetState( protocol = Conversation.ProtocolInfo.Proteus, mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, - isUnderLegalHold = isLegalHold + isUnderLegalHold = showLegalHoldIndicator, + isFavorite = isFavorite ) } } @@ -102,7 +103,8 @@ fun rememberConversationSheetState( protocol = Conversation.ProtocolInfo.Proteus, mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, - isUnderLegalHold = isLegalHold + isUnderLegalHold = showLegalHoldIndicator, + isFavorite = isFavorite ) } } @@ -122,7 +124,8 @@ fun rememberConversationSheetState( protocol = Conversation.ProtocolInfo.Proteus, mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, - isUnderLegalHold = isLegalHold + isUnderLegalHold = showLegalHoldIndicator, + isFavorite = null ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/HomeSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/HomeSheetContent.kt index ab514914c85..c06603826aa 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/HomeSheetContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/HomeSheetContent.kt @@ -53,12 +53,14 @@ import com.wire.android.ui.theme.wireTypography import com.wire.kalium.logic.data.conversation.MutedConversationStatus import com.wire.kalium.logic.data.user.ConnectionState +// items cannot be simplified +@Suppress("CyclomaticComplexMethod") @Composable internal fun ConversationMainSheetContent( conversationSheetContent: ConversationSheetContent, -// TODO(profile): enable when implemented -// addConversationToFavourites: () -> Unit, -// moveConversationToFolder: () -> Unit, + changeFavoriteState: (dialogState: GroupDialogState, addToFavorite: Boolean) -> Unit, + // TODO(profile): enable when implemented + // moveConversationToFolder: () -> Unit, updateConversationArchiveStatus: (DialogState) -> Unit, clearConversationContent: (DialogState) -> Unit, blockUserClick: (BlockUserDialogState) -> Unit, @@ -108,21 +110,38 @@ internal fun ConversationMainSheetContent( ) } } + + if (conversationSheetContent.canAddToFavourite() && !conversationSheetContent.isArchived) { + conversationSheetContent.isFavorite?.let { isFavorite -> + add { + MenuBottomSheetItem( + title = stringResource( + if (isFavorite) { + R.string.label_remove_from_favourites + } else { + R.string.label_add_to_favourites + } + ), + leading = { + MenuItemIcon( + id = R.drawable.ic_favourite, + contentDescription = null + ) + }, + onItemClick = { + changeFavoriteState( + GroupDialogState( + conversationSheetContent.conversationId, + conversationSheetContent.title + ), + !isFavorite + ) + } + ) + } + } + } // TODO(profile): enable when implemented -// -// if (conversationSheetContent.canAddToFavourite()) -// add { -// MenuBottomSheetItem( -// title = stringResource(R.string.label_add_to_favourites), -// icon = { -// MenuItemIcon( -// id = R.drawable.ic_favourite, -// contentDescription = stringResource(R.string.content_description_add_to_favourite), -// ) -// }, -// onItemClick = addConversationToFavourites -// ) -// } // add { // MenuBottomSheetItem( // icon = { diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/folder/ChangeConversationFavoriteStateArgs.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/folder/ChangeConversationFavoriteStateArgs.kt new file mode 100644 index 00000000000..a3dd9b6b5b7 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/folder/ChangeConversationFavoriteStateArgs.kt @@ -0,0 +1,26 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.bottomsheet.folder + +import com.wire.android.di.ScopedArgs +import kotlinx.serialization.Serializable + +@Serializable +object ChangeConversationFavoriteStateArgs : ScopedArgs { + override val key = "ConnectionActionButtonArgsKey" +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/folder/ChangeConversationFavoriteVM.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/folder/ChangeConversationFavoriteVM.kt new file mode 100644 index 00000000000..6d2c0a14cd2 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/folder/ChangeConversationFavoriteVM.kt @@ -0,0 +1,71 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.common.bottomsheet.folder + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.R +import com.wire.android.di.ViewModelScopedPreview +import com.wire.android.model.SnackBarMessage +import com.wire.android.model.asSnackBarMessage +import com.wire.android.ui.home.conversationslist.model.GroupDialogState +import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.feature.conversation.folder.AddConversationToFavoritesUseCase +import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFavoritesUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ViewModelScopedPreview +interface ChangeConversationFavoriteVM { + val infoMessage: SharedFlow + get() = MutableSharedFlow() + + fun changeFavoriteState(dialogState: GroupDialogState, addToFavorite: Boolean) {} +} + +@HiltViewModel +class ChangeConversationFavoriteVMImpl @Inject constructor( + private val addConversationToFavorites: AddConversationToFavoritesUseCase, + private val removeConversationFromFavorites: RemoveConversationFromFavoritesUseCase, +) : ChangeConversationFavoriteVM, ViewModel() { + + private val _infoMessage = MutableSharedFlow() + override val infoMessage = _infoMessage.asSharedFlow() + + override fun changeFavoriteState(dialogState: GroupDialogState, addToFavorite: Boolean) { + viewModelScope.launch { + val messageResource = if (addToFavorite) { + when (addConversationToFavorites(dialogState.conversationId)) { + is AddConversationToFavoritesUseCase.Result.Failure -> R.string.error_adding_to_favorite + AddConversationToFavoritesUseCase.Result.Success -> R.string.success_adding_to_favorite + } + } else { + when (removeConversationFromFavorites(dialogState.conversationId)) { + is RemoveConversationFromFavoritesUseCase.Result.Failure -> R.string.error_removing_from_favorite + RemoveConversationFromFavoritesUseCase.Result.Success -> R.string.success_removing_from_favorite + } + } + _infoMessage.emit(UIText.StringResource(messageResource, dialogState.conversationName).asSnackBarMessage()) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt index af7d5553dde..1f19379769b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt @@ -69,7 +69,7 @@ fun CodeTextField( maxHorizontalSpacing = maxHorizontalSpacing, horizontalAlignment = horizontalAlignment, modifier = modifier, - innerBasicTextField = { decorator, textFieldModifier -> + innerBasicTextField = { decorator, textFieldModifier, _ -> BasicTextField( state = textState, textStyle = textStyle, diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt index d05c9acf38b..60bcadf6e54 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt @@ -105,6 +105,7 @@ internal fun CodeTextFieldLayout( } }, textFieldModifier = Modifier, + decorationBox = {} ) val bottomText = when { state is WireTextFieldState.Error && state.errorText != null -> state.errorText diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandler.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandler.kt new file mode 100644 index 00000000000..9f34070fc6f --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandler.kt @@ -0,0 +1,50 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.textfield + +import androidx.compose.ui.text.TextRange + +object MentionDeletionHandler { + @Suppress("ReturnCount") + fun handle( + oldText: String, + newText: String, + oldSelection: TextRange, + mentions: List + ): String { + if (oldText == newText) { + // No change in text, only cursor movement, return as is + return oldText + } + for (mention in mentions) { + // Find the start position of the mention in the text + val mentionStart = oldText.indexOf(mention) + + if (mentionStart == -1) continue + + val mentionEnd = mentionStart + mention.length + + // Check if the selection (i.e., user's cursor position) is inside the mention's range + if (oldSelection.start in mentionStart + 1..mentionEnd || oldSelection.end in mentionStart + 1..mentionEnd) { + // If the user is deleting inside the mention, remove the entire mention + return oldText.removeRange(mentionStart, mentionEnd) + } + } + return newText + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionVisualTransformation.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionVisualTransformation.kt new file mode 100644 index 00000000000..ebd399766e1 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionVisualTransformation.kt @@ -0,0 +1,60 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.textfield + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.withStyle +import com.wire.android.ui.home.conversations.model.UIMention + +class MentionVisualTransformation( + val color: Color, + val mentions: List +) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val styledText = buildAnnotatedString { + var lastIndex = 0 + text.takeIf { it.isNotEmpty() }?.let { + mentions.forEach { mention -> + // Append the text before the mention + append(text.subSequence(lastIndex, mention.start)) + // Apply the style to the mention + withStyle(style = SpanStyle(color = color, fontWeight = FontWeight.Bold)) { + append(text.subSequence(mention.start, mention.start + mention.length)) + } + lastIndex = mention.start + mention.length + } + } + // Append the remaining text after the last mention + append(text.subSequence(lastIndex, text.length)) + } + return TransformedText( + text = styledText, + offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = offset + override fun transformedToOriginal(offset: Int): Int = offset + } + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt index 87626e8f3aa..c87cf1fdb2f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt @@ -110,7 +110,7 @@ fun WirePasswordTextField( modifier = modifier.then(autoFillModifier(autoFillType, textState::setTextAndPlaceCursorAtEnd)), testTag = testTag, onTap = onTap, - innerBasicTextField = { decorator, textFieldModifier -> + innerBasicTextField = { decorator, textFieldModifier, _ -> BasicSecureTextField( state = textState, textStyle = textStyle.copy(color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr), diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt index 0bf843388d9..4ee35be59bb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.KeyboardActionHandler @@ -42,6 +43,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -53,10 +56,13 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.home.conversations.model.UIMention import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography @@ -134,7 +140,7 @@ internal fun WireTextField( ), onTap = onTap, testTag = testTag, - innerBasicTextField = { decorator, textFieldModifier -> + innerBasicTextField = { decorator, textFieldModifier, _ -> BasicTextField( state = textState, textStyle = textStyle.copy( @@ -163,6 +169,107 @@ internal fun WireTextField( ) } +@Composable +internal fun WireTextField( + textFieldValue: State, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + mentions: List = emptyList(), + placeholderText: String? = null, + labelText: String? = null, + labelMandatoryIcon: Boolean = false, + descriptionText: String? = null, + semanticDescription: String? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + state: WireTextFieldState = WireTextFieldState.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.DefaultText, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + textStyle: TextStyle = MaterialTheme.wireTypography.body01, + placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01, + placeholderAlignment: Alignment.Horizontal = Alignment.Start, + inputMinHeight: Dp = MaterialTheme.wireDimensions.textFieldMinHeight, + shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), + colors: WireTextFieldColors = wireTextFieldColors(), + onSelectedLineIndexChanged: (Int) -> Unit = { }, + onLineBottomYCoordinateChanged: (Float) -> Unit = { }, + onTap: ((Offset) -> Unit)? = null, + testTag: String = String.EMPTY +) { + WireTextFieldLayout( + modifier = modifier, + shouldShowPlaceholder = textFieldValue.value.text.isEmpty(), + placeholderText = placeholderText, + labelText = labelText, + labelMandatoryIcon = labelMandatoryIcon, + descriptionText = descriptionText, + semanticDescription = semanticDescription, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + state = state, + interactionSource = interactionSource, + placeholderTextStyle = placeholderTextStyle, + placeholderAlignment = placeholderAlignment, + inputMinHeight = inputMinHeight, + shape = shape, + colors = colors, + onTap = onTap, + testTag = testTag, + innerBasicTextField = { _, textFieldModifier, decoratorBox -> + BasicTextField( + value = textFieldValue.value, + onValueChange = { newText -> + val mentionsByName = mentions.map { it.handler } + val updatedText = + MentionDeletionHandler.handle( + textFieldValue.value.text, + newText.text, + textFieldValue.value.selection, + mentionsByName + ) + onValueChange(TextFieldValue(updatedText, newText.selection)) + }, + textStyle = textStyle.copy( + color = colors.textColor(state = state).value, + textDirection = TextDirection.ContentOrLtr + ), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + readOnly = state is WireTextFieldState.ReadOnly, + enabled = state !is WireTextFieldState.Disabled, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + interactionSource = interactionSource, + modifier = textFieldModifier, + decorationBox = decoratorBox, + onTextLayout = onTextLayout( + textFieldValue, + onSelectedLineIndexChanged, + onLineBottomYCoordinateChanged + ), + visualTransformation = MentionVisualTransformation(colorsScheme().primary, mentions), + ) + } + ) +} + +private fun onTextLayout( + textFieldValue: State, + onSelectedLineIndexChanged: (Int) -> Unit = { }, + onLineBottomYCoordinateChanged: (Float) -> Unit = { }, +): (TextLayoutResult) -> Unit = { + val lineOfText = it.getLineForOffset(textFieldValue.value.selection.end) + val bottomYCoordinate = it.getLineBottom(lineOfText) + onSelectedLineIndexChanged(lineOfText) + onLineBottomYCoordinateChanged(bottomYCoordinate) +} + private fun onTextLayout( state: TextFieldState, onSelectedLineIndexChanged: (Int) -> Unit = { }, @@ -203,6 +310,16 @@ private fun KeyboardOptions.Companion.defaultEmail(imeAction: ImeAction): Keyboa ) } +@PreviewMultipleThemes +@Composable +fun PreviewWireTextFieldWithTextFieldValue() = WireTheme { + WireTextField( + modifier = Modifier.padding(16.dp), + textFieldValue = remember { mutableStateOf(TextFieldValue("text")) }, + onValueChange = {} + ) +} + @PreviewMultipleThemes @Composable fun PreviewWireTextField() = WireTheme { diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt index b99c98af954..d93d1e73309 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt @@ -111,6 +111,21 @@ internal fun WireTextFieldLayout( onTap = onTap, ) }, + decorationBox = { innerTextField -> + InnerTextLayout( + innerTextField = innerTextField, + shouldShowPlaceholder = shouldShowPlaceholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + placeholderText = placeholderText, + style = state, + placeholderTextStyle = placeholderTextStyle, + placeholderAlignment = placeholderAlignment, + inputMinHeight = inputMinHeight, + colors = colors, + onTap = onTap, + ) + }, textFieldModifier = Modifier .fillMaxWidth() .background(color = colors.backgroundColor(state).value, shape = shape) @@ -218,5 +233,9 @@ private fun Alignment.Horizontal.toAlignment(): Alignment = Alignment { size, sp fun interface InnerBasicTextFieldBuilder { @Composable - fun Build(decorator: TextFieldDecorator, textFieldModifier: Modifier) + fun Build( + decorator: TextFieldDecorator, + textFieldModifier: Modifier, + decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt index f2ab8d539ad..f5a247fced9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt @@ -69,6 +69,11 @@ fun HomeTopBar( ) } val openLabel = stringResource(R.string.content_description_open_label) + val contentDescription = if (shouldShowCreateTeamUnreadIndicator) { + stringResource(R.string.content_description_home_profile_btn_with_notification) + } else { + stringResource(R.string.content_description_home_profile_btn) + } UserProfileAvatar( avatarData = userAvatarData, clickable = remember { @@ -76,7 +81,7 @@ fun HomeTopBar( }, type = UserProfileAvatarType.WithIndicators.RegularUser(legalHoldIndicatorVisible = withLegalHoldIndicator), shouldShowCreateTeamUnreadIndicator = shouldShowCreateTeamUnreadIndicator, - contentDescription = stringResource(R.string.content_description_home_profile_btn) + contentDescription = contentDescription ) }, elevation = elevation, 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 4677a8fae12..0087bc2f6eb 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 @@ -295,7 +295,7 @@ fun ConversationScreen( LaunchedEffect(messageDraftViewModel.state.value.quotedMessageId) { val compositionState = messageDraftViewModel.state.value if (compositionState.quotedMessage != null) { - messageComposerStateHolder.messageCompositionHolder.updateQuote(compositionState.quotedMessage) + messageComposerStateHolder.messageCompositionHolder.value.updateQuote(compositionState.quotedMessage) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt index a6d19e62bc7..b067f167aeb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt @@ -63,6 +63,7 @@ import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.result.ResultRecipient import com.wire.android.R import com.wire.android.appLogger +import com.wire.android.di.hiltViewModelScoped import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.WireDestination @@ -78,6 +79,9 @@ import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout import com.wire.android.ui.common.bottomsheet.conversation.ConversationSheetContent import com.wire.android.ui.common.bottomsheet.conversation.ConversationTypeDetail import com.wire.android.ui.common.bottomsheet.conversation.rememberConversationSheetState +import com.wire.android.ui.common.bottomsheet.folder.ChangeConversationFavoriteStateArgs +import com.wire.android.ui.common.bottomsheet.folder.ChangeConversationFavoriteVM +import com.wire.android.ui.common.bottomsheet.folder.ChangeConversationFavoriteVMImpl import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.bottomsheet.show import com.wire.android.ui.common.button.WirePrimaryButton @@ -285,7 +289,11 @@ private fun GroupConversationDetailsContent( isLoading: Boolean, isAbandonedOneOnOneConversation: Boolean, onSearchConversationMessagesClick: () -> Unit, - onConversationMediaClick: () -> Unit + onConversationMediaClick: () -> Unit, + changeConversationFavoriteStateViewModel: ChangeConversationFavoriteVM = + hiltViewModelScoped( + ChangeConversationFavoriteStateArgs + ), ) { val scope = rememberCoroutineScope() val resources = LocalContext.current.resources @@ -461,7 +469,7 @@ private fun GroupConversationDetailsContent( ) } }, - addConversationToFavourites = bottomSheetEventsHandler::onAddConversationToFavourites, + changeFavoriteState = changeConversationFavoriteStateViewModel::changeFavoriteState, moveConversationToFolder = bottomSheetEventsHandler::onMoveConversationToFolder, updateConversationArchiveStatus = { // Only show the confirmation dialog if the conversation is not archived @@ -597,7 +605,8 @@ fun PreviewGroupConversationDetails() { ), mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, isUnderLegalHold = false, - proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED + proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, + isFavorite = false ), bottomSheetEventsHandler = GroupConversationDetailsBottomSheetEventsHandler.PREVIEW, onBackPressed = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt index 31063e6a052..6878238a949 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt @@ -73,6 +73,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn @@ -150,7 +151,10 @@ class GroupConversationDetailsViewModel @Inject constructor( title = groupDetails.conversation.name.orEmpty(), conversationId = conversationId, mutingConversationState = groupDetails.conversation.mutedStatus, - conversationTypeDetail = ConversationTypeDetail.Group(conversationId, groupDetails.isSelfUserCreator), + conversationTypeDetail = ConversationTypeDetail.Group( + conversationId = conversationId, + isFromTheSameTeam = groupDetails.conversation.teamId == observerSelfUser().firstOrNull()?.teamId + ), isTeamConversation = groupDetails.conversation.teamId?.value != null, selfRole = groupDetails.selfRole, isArchived = groupDetails.conversation.archived, @@ -158,6 +162,7 @@ class GroupConversationDetailsViewModel @Inject constructor( mlsVerificationStatus = groupDetails.conversation.mlsVerificationStatus, proteusVerificationStatus = groupDetails.conversation.proteusVerificationStatus, isUnderLegalHold = groupDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), + isFavorite = groupDetails.isFavorite ) updateState( @@ -374,10 +379,6 @@ class GroupConversationDetailsViewModel @Inject constructor( } } - @Suppress("EmptyFunctionBlock") - override fun onAddConversationToFavourites(conversationId: ConversationId?) { - } - @Suppress("EmptyFunctionBlock") override fun onMoveConversationToFolder(conversationId: ConversationId?) { } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/GroupConversationDetailsBottomSheetEventsHandler.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/GroupConversationDetailsBottomSheetEventsHandler.kt index 16a000ea627..6a0d8132b13 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/GroupConversationDetailsBottomSheetEventsHandler.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/GroupConversationDetailsBottomSheetEventsHandler.kt @@ -27,7 +27,6 @@ import com.wire.kalium.util.DateTimeUtil @Suppress("TooManyFunctions") interface GroupConversationDetailsBottomSheetEventsHandler { fun onMutingConversationStatusChange(conversationId: ConversationId?, status: MutedConversationStatus, onMessage: (UIText) -> Unit) - fun onAddConversationToFavourites(conversationId: ConversationId? = null) fun onMoveConversationToFolder(conversationId: ConversationId? = null) fun updateConversationArchiveStatus( dialogState: DialogState, @@ -47,7 +46,6 @@ interface GroupConversationDetailsBottomSheetEventsHandler { ) { } - override fun onAddConversationToFavourites(conversationId: ConversationId?) {} override fun onMoveConversationToFolder(conversationId: ConversationId?) {} override fun updateConversationArchiveStatus( dialogState: DialogState, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsEmptyScreenText.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsEmptyScreenText.kt index 24b3997ac1a..b9da133ddf9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsEmptyScreenText.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsEmptyScreenText.kt @@ -22,11 +22,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -72,7 +72,7 @@ fun MessageDetailsEmptyScreenText( append(learnMoreText) addStyle( style = SpanStyle( - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.onBackground, textDecoration = TextDecoration.Underline ), start = 0, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsReactions.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsReactions.kt index 730aa155648..8d869728968 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsReactions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsReactions.kt @@ -30,6 +30,7 @@ import com.wire.android.R import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.home.conversations.details.participants.folderWithElements import com.wire.android.ui.home.conversations.messagedetails.model.MessageDetailsReactionsData +import com.wire.android.ui.theme.WireTheme @Composable fun MessageDetailsReactions( @@ -69,8 +70,10 @@ fun MessageDetailsReactions( @MultipleThemePreviews @Composable fun PreviewMessageDetailsReactions() { - MessageDetailsReactions( - reactionsData = MessageDetailsReactionsData(), - onReactionsLearnMore = {} - ) + WireTheme { + MessageDetailsReactions( + reactionsData = MessageDetailsReactionsData(), + onReactionsLearnMore = {} + ) + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsReadReceipts.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsReadReceipts.kt index 5de9f7407d9..4310a037bab 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsReadReceipts.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsReadReceipts.kt @@ -26,10 +26,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import com.wire.android.R +import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.home.conversations.details.participants.folderWithElements import com.wire.android.ui.home.conversations.messagedetails.model.MessageDetailsReadReceiptsData +import com.wire.android.ui.theme.WireTheme @Composable fun MessageDetailsReadReceipts( @@ -64,11 +65,13 @@ fun MessageDetailsReadReceipts( } } -@Preview(showBackground = true) +@MultipleThemePreviews @Composable fun PreviewMessageDetailsReadReceipts() { - MessageDetailsReadReceipts( - readReceiptsData = MessageDetailsReadReceiptsData(), - onReadReceiptsLearnMore = {} - ) + WireTheme { + MessageDetailsReadReceipts( + readReceiptsData = MessageDetailsReadReceiptsData(), + onReadReceiptsLearnMore = {} + ) + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index d7ffb2d2165..84aef9ce71f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -26,7 +26,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.wire.android.R import com.wire.android.appLogger -import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayerProvider import com.wire.android.model.SnackBarMessage import com.wire.android.navigation.SavedStateViewModel import com.wire.android.ui.home.conversations.ConversationNavArgs @@ -98,7 +98,7 @@ class ConversationMessagesViewModel @Inject constructor( private val getMessageForConversation: GetMessagesForConversationUseCase, private val toggleReaction: ToggleReactionUseCase, private val resetSession: ResetSessionUseCase, - private val conversationAudioMessagePlayer: ConversationAudioMessagePlayer, + private val conversationAudioMessagePlayerProvider: ConversationAudioMessagePlayerProvider, private val getConversationUnreadEventsCount: GetConversationUnreadEventsCountUseCase, private val clearUsersTypingEvents: ClearUsersTypingEventsUseCase, private val getSearchedConversationMessagePosition: GetSearchedConversationMessagePositionUseCase, @@ -108,6 +108,7 @@ class ConversationMessagesViewModel @Inject constructor( private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() val conversationId: QualifiedID = conversationNavArgs.conversationId private val searchedMessageIdNavArgs: String? = conversationNavArgs.searchedMessageId + private val conversationAudioMessagePlayer = conversationAudioMessagePlayerProvider.provide() var conversationViewState by mutableStateOf( ConversationMessagesViewState( @@ -436,7 +437,7 @@ class ConversationMessagesViewModel @Inject constructor( override fun onCleared() { super.onCleared() - conversationAudioMessagePlayer.close() + conversationAudioMessagePlayerProvider.onCleared() } private companion object { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt index 57f69af60cb..b573aa57c02 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.conversations.messages.item -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -67,7 +66,6 @@ import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.asset.AssetTransferStatus // TODO: a definite candidate for a refactor and cleanup WPB-14390 -@OptIn(ExperimentalFoundationApi::class) @Suppress("ComplexMethod") @Composable fun RegularMessageItem( @@ -278,7 +276,7 @@ private fun MessageAuthorRow(messageHeader: MessageHeader) { startPadding = dimensions().spacing6x, isDeleted = isSenderDeleted ) - if (isLegalHold) { + if (showLegalHoldIndicator) { LegalHoldIndicator(modifier = Modifier.padding(start = dimensions().spacing6x)) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt index 486e07f5dc2..e7441175174 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt @@ -109,7 +109,6 @@ fun SystemMessageItem( text = annotatedString, linkText = learnMoreLink?.let { stringResource(id = R.string.label_learn_more) }, textColor = MaterialTheme.wireColorScheme.secondaryText, - linkColor = MaterialTheme.wireColorScheme.onBackground, onLinkClick = { learnMoreLink?.let { CustomTabsHelper.launchUrl(context, it) } }, onTextLayout = { centerOfFirstLine = if (it.lineCount == 0) 0f else ((it.getLineTop(0) + it.getLineBottom(0)) / 2) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt index 7b0461a57bc..b995681aed8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt @@ -65,7 +65,7 @@ val mockMessageTime = MessageTime(Instant.fromEpochSeconds(MOCK_TIME_IN_SECONDS) val mockHeader = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -307,7 +307,7 @@ fun mockAssetMessage(assetId: String = "asset1", messageId: String = "msg1") = U header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -337,7 +337,7 @@ fun mockAssetAudioMessage(assetId: String = "asset1", messageId: String = "msg1" header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -396,7 +396,7 @@ fun mockedImageUIMessage( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = messageStatus, messageId = messageId, @@ -417,7 +417,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -447,7 +447,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Delivered, isDeleted = true, @@ -468,7 +468,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -490,7 +490,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -512,7 +512,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Delivered, @@ -543,7 +543,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -565,7 +565,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, 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 c3f70e439d8..bd507cdf380 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 @@ -127,7 +127,7 @@ sealed interface UIMessage { data class MessageHeader( val username: UIText, val membership: Membership, - val isLegalHold: Boolean, + val showLegalHoldIndicator: Boolean, val messageTime: MessageTime, val messageStatus: MessageStatus, val messageId: String, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt index 7c7d830bd9a..ebb17ba5ea8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt @@ -28,10 +28,12 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import com.wire.android.R +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.util.EMPTY import com.wire.android.util.QueryMatchExtractor +import com.wire.android.util.ui.PreviewMultipleThemes @Composable fun HighlightName( @@ -93,3 +95,14 @@ fun HighlightName( @Composable private fun String.isUnknownUser() = this == stringResource(id = R.string.username_unavailable_label) + +@PreviewMultipleThemes +@Composable +fun PreviewHighlightName() { + WireTheme { + HighlightName( + name = "John Doe", + searchQuery = "John" + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt index c9663f89cfd..d2a85417d42 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt @@ -33,7 +33,9 @@ import com.wire.kalium.logic.data.conversation.ConversationQueryConfig import com.wire.kalium.logic.feature.conversation.GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase import com.wire.kalium.logic.feature.conversation.folder.GetFavoriteFolderUseCase import com.wire.kalium.logic.feature.conversation.folder.ObserveConversationsFromFolderUseCase +import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -46,6 +48,7 @@ class GetConversationsFromSearchUseCase @Inject constructor( private val wireSessionImageLoader: WireSessionImageLoader, private val userTypeMapper: UserTypeMapper, private val dispatchers: DispatcherProvider, + private val observeSelfUser: GetSelfUserUseCase ) { suspend operator fun invoke( searchQuery: String = "", @@ -95,7 +98,12 @@ class GetConversationsFromSearchUseCase @Inject constructor( } .map { pagingData -> pagingData.map { - it.toConversationItem(wireSessionImageLoader, userTypeMapper, searchQuery) + it.toConversationItem( + wireSessionImageLoader = wireSessionImageLoader, + userTypeMapper = userTypeMapper, + searchQuery = searchQuery, + selfUserTeamId = observeSelfUser().firstOrNull()?.teamId + ) } }.flowOn(dispatchers.io()) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index cbea3a7fcdd..c17ba56bb96 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.insertSeparators +import androidx.paging.map import com.wire.android.BuildConfig import com.wire.android.appLogger import com.wire.android.di.CurrentAccount @@ -64,9 +65,12 @@ import com.wire.kalium.logic.feature.conversation.RefreshConversationsWithoutMet import com.wire.kalium.logic.feature.conversation.RemoveMemberFromConversationUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationArchivedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationMutedStatusUseCase +import com.wire.kalium.logic.feature.legalhold.LegalHoldStateForSelfUser +import com.wire.kalium.logic.feature.legalhold.ObserveLegalHoldStateForSelfUserUseCase import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase import com.wire.kalium.logic.feature.team.DeleteTeamConversationUseCase import com.wire.kalium.logic.feature.team.Result +import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.util.DateTimeUtil import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -78,9 +82,11 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -106,7 +112,6 @@ interface ConversationListViewModel { fun leaveGroup(leaveGroupState: GroupDialogState) {} fun clearConversationContent(dialogState: DialogState) {} fun muteConversation(conversationId: ConversationId?, mutedConversationStatus: MutedConversationStatus) {} - fun addConversationToFavourites() {} fun moveConversationToFolder() {} fun searchQueryChanged(searchQuery: String) {} } @@ -121,6 +126,7 @@ class ConversationListViewModelPreview( @HiltViewModel(assistedFactory = ConversationListViewModelImpl.Factory::class) class ConversationListViewModelImpl @AssistedInject constructor( @Assisted val conversationsSource: ConversationsSource, + @Assisted private val usePagination: Boolean = BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED, dispatcher: DispatcherProvider, private val updateConversationMutedStatus: UpdateConversationMutedStatusUseCase, private val getConversationsPaginated: GetConversationsFromSearchUseCase, @@ -133,14 +139,19 @@ class ConversationListViewModelImpl @AssistedInject constructor( private val refreshUsersWithoutMetadata: RefreshUsersWithoutMetadataUseCase, private val refreshConversationsWithoutMetadata: RefreshConversationsWithoutMetadataUseCase, private val updateConversationArchivedStatus: UpdateConversationArchivedStatusUseCase, + private val observeLegalHoldStateForSelfUser: ObserveLegalHoldStateForSelfUserUseCase, @CurrentAccount val currentAccount: UserId, private val wireSessionImageLoader: WireSessionImageLoader, private val userTypeMapper: UserTypeMapper, + private val observeSelfUser: GetSelfUserUseCase ) : ConversationListViewModel, ViewModel() { @AssistedFactory interface Factory { - fun create(conversationsSource: ConversationsSource): ConversationListViewModelImpl + fun create( + conversationsSource: ConversationsSource, + usePagination: Boolean = BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED, + ): ConversationListViewModelImpl } private val _infoMessage = MutableSharedFlow() @@ -173,7 +184,11 @@ class ConversationListViewModelImpl @AssistedInject constructor( conversationFilter = conversationsSource.toFilter(), onlyInteractionEnabled = false, newActivitiesOnTop = containsNewActivitiesSection, - ).map { + ).combine(observeLegalHoldStateForSelfUser()) { conversations, selfUserLegalHoldStatus -> + conversations.map { + it.hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus) + } + }.map { it.insertSeparators { before, after -> when { // do not add separators if the list shouldn't show conversations grouped into different folders @@ -200,7 +215,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( private var notPaginatedConversationListState by mutableStateOf(ConversationListState.NotPaginated()) override val conversationListState: ConversationListState - get() = if (BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED) { + get() = if (usePagination) { ConversationListState.Paginated( conversations = conversationsPaginatedFlow, domain = currentAccount.domain @@ -210,7 +225,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( } init { - if (!BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED) { + if (!usePagination) { viewModelScope.launch { searchQueryFlow .debounce { if (it.isEmpty()) 0L else DEFAULT_SEARCH_QUERY_DEBOUNCE } @@ -220,13 +235,14 @@ class ConversationListViewModelImpl @AssistedInject constructor( observeConversationListDetailsWithEvents( fromArchive = conversationsSource == ConversationsSource.ARCHIVE, conversationFilter = conversationsSource.toFilter() - ).map { - it.map { conversationDetails -> + ).combine(observeLegalHoldStateForSelfUser()) { conversations, selfUserLegalHoldStatus -> + conversations.map { conversationDetails -> conversationDetails.toConversationItem( wireSessionImageLoader = wireSessionImageLoader, userTypeMapper = userTypeMapper, searchQuery = searchQuery, - ) + selfUserTeamId = observeSelfUser().firstOrNull()?.teamId + ).hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus) } to searchQuery } } @@ -354,11 +370,6 @@ class ConversationListViewModelImpl @AssistedInject constructor( } } - // TODO: needs to be implemented - @Suppress("EmptyFunctionBlock") - override fun addConversationToFavourites() { - } - // TODO: needs to be implemented @Suppress("EmptyFunctionBlock") override fun moveConversationToFolder() { @@ -438,6 +449,19 @@ private fun ConversationsSource.toFilter(): ConversationFilter = when (this) { ConversationsSource.ONE_ON_ONE -> ConversationFilter.ONE_ON_ONE } +private fun ConversationItem.hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus: LegalHoldStateForSelfUser) = + // if self user is under legal hold then we shouldn't show legal hold indicator next to every conversation + // the indication is shown in the header of the conversation list for self user in that case and it's enough + when (selfUserLegalHoldStatus) { + is LegalHoldStateForSelfUser.Enabled -> when (this) { + is ConversationItem.ConnectionConversation -> this.copy(showLegalHoldIndicator = false) + is ConversationItem.GroupConversation -> this.copy(showLegalHoldIndicator = false) + is ConversationItem.PrivateConversation -> this.copy(showLegalHoldIndicator = false) + } + + else -> this + } + @Suppress("ComplexMethod") private fun List.withFolders(source: ConversationsSource): Map> { return when (source) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index 4d29db15cb6..244fc837067 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -33,6 +33,7 @@ import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems import com.wire.android.R import com.wire.android.appLogger +import com.wire.android.di.hiltViewModelScoped import com.wire.android.feature.analytics.AnonymousAnalyticsManagerImpl import com.wire.android.feature.analytics.model.AnalyticsEvent import com.wire.android.navigation.NavigationCommand @@ -44,6 +45,9 @@ import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout import com.wire.android.ui.common.bottomsheet.conversation.ConversationOptionNavigation import com.wire.android.ui.common.bottomsheet.conversation.ConversationSheetContent import com.wire.android.ui.common.bottomsheet.conversation.rememberConversationSheetState +import com.wire.android.ui.common.bottomsheet.folder.ChangeConversationFavoriteStateArgs +import com.wire.android.ui.common.bottomsheet.folder.ChangeConversationFavoriteVM +import com.wire.android.ui.common.bottomsheet.folder.ChangeConversationFavoriteVMImpl import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.dialogs.ArchiveConversationDialog import com.wire.android.ui.common.dialogs.BlockUserDialogContent @@ -98,6 +102,10 @@ fun ConversationsScreenContent( LocalInspectionMode.current -> ConversationCallListViewModelPreview else -> hiltViewModel(key = "call_${conversationsSource.name}") }, + changeConversationFavoriteStateViewModel: ChangeConversationFavoriteVM = + hiltViewModelScoped( + ChangeConversationFavoriteStateArgs + ), ) { var currentConversationOptionNavigation by remember { mutableStateOf(ConversationOptionNavigation.Home) @@ -304,7 +312,7 @@ fun ConversationsScreenContent( mutedConversationStatus = conversationState.conversationSheetContent!!.mutingConversationState ) }, - addConversationToFavourites = conversationListViewModel::addConversationToFavourites, + changeFavoriteState = changeConversationFavoriteStateViewModel::changeFavoriteState, moveConversationToFolder = conversationListViewModel::moveConversationToFolder, updateConversationArchiveStatus = showConfirmationDialogOrUnarchive(), clearConversationContent = clearContentDialogState::show, @@ -318,6 +326,9 @@ fun ConversationsScreenContent( } SnackBarMessageHandler(infoMessages = conversationListViewModel.infoMessage) + SnackBarMessageHandler(infoMessages = changeConversationFavoriteStateViewModel.infoMessage, onEmitted = { + sheetState.hide() + }) } private const val TAG = "BaseConversationsScreen" diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt index 44ac89e3f7a..e7311dbe4ed 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt @@ -159,7 +159,7 @@ private fun GeneralConversationItem( title = { ConversationTitle( name = groupName.ifEmpty { stringResource(id = R.string.member_name_deleted_label) }, - isLegalHold = conversation.isLegalHold, + showLegalHoldIndicator = conversation.showLegalHoldIndicator, searchQuery = searchQuery ) }, @@ -310,8 +310,10 @@ fun PreviewGroupConversationItemWithUnreadCount() = WireTheme { selfMemberRole = null, teamId = null, isArchived = false, + isFromTheSameTeam = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + isFavorite = false ), modifier = Modifier, isSelectableItem = false, @@ -335,8 +337,10 @@ fun PreviewGroupConversationItemWithNoBadges() = WireTheme { selfMemberRole = null, teamId = null, isArchived = false, + isFromTheSameTeam = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + isFavorite = false ), modifier = Modifier, isSelectableItem = false, @@ -362,8 +366,10 @@ fun PreviewGroupConversationItemWithLastDeletedMessage() = WireTheme { selfMemberRole = null, teamId = null, isArchived = false, + isFromTheSameTeam = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + isFavorite = false ), modifier = Modifier, isSelectableItem = false, @@ -387,8 +393,10 @@ fun PreviewGroupConversationItemWithMutedBadgeAndUnreadMentionBadge() = WireThem selfMemberRole = null, teamId = null, isArchived = false, + isFromTheSameTeam = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + isFavorite = false ), modifier = Modifier, isSelectableItem = false, @@ -413,8 +421,10 @@ fun PreviewGroupConversationItemWithOngoingCall() = WireTheme { teamId = null, hasOnGoingCall = true, isArchived = false, + isFromTheSameTeam = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + isFavorite = false ), modifier = Modifier, isSelectableItem = false, @@ -496,7 +506,8 @@ fun PreviewPrivateConversationItemWithBlockedBadge() = WireTheme { userId = UserId("value", "domain"), isArchived = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + isFavorite = false ), modifier = Modifier, isSelectableItem = false, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt index e57d257d11d..730ef25eaf7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt @@ -201,9 +201,11 @@ fun previewConversationList(count: Int, startIndex: Int = 0, unread: Boolean = f teamId = null, hasOnGoingCall = false, isArchived = false, + isFromTheSameTeam = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, searchQuery = searchQuery, + isFavorite = false ) ) @@ -222,6 +224,7 @@ fun previewConversationList(count: Int, startIndex: Int = 0, unread: Boolean = f mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, searchQuery = searchQuery, + isFavorite = false ) ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationTitle.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationTitle.kt index 0382e16960e..1bbf7362869 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationTitle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationTitle.kt @@ -38,7 +38,7 @@ fun ConversationTitle( name: String, searchQuery: String, modifier: Modifier = Modifier, - isLegalHold: Boolean = false, + showLegalHoldIndicator: Boolean = false, badges: @Composable () -> Unit = {} ) { Row( @@ -57,7 +57,7 @@ fun ConversationTitle( HighlightName(name = name, searchQuery = searchQuery) } badges() - if (isLegalHold) { + if (showLegalHoldIndicator) { Spacer(modifier = Modifier.width(6.dp)) LegalHoldIndicator() } @@ -67,5 +67,5 @@ fun ConversationTitle( @Preview(widthDp = 200) @Composable fun PreviewConversationTitle() { - ConversationTitle("very very loooooooooooong name", searchQuery = "test", isLegalHold = true) + ConversationTitle("very very loooooooooooong name", searchQuery = "test", showLegalHoldIndicator = true) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt index 0daf7203d99..c7ec40a8d7d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt @@ -39,7 +39,7 @@ fun UserLabel( with(userInfoLabel) { ConversationTitle( name = if (unavailable) stringResource(id = R.string.username_unavailable_label) else labelName, - isLegalHold = isLegalHold, + showLegalHoldIndicator = showLegalHoldIndicator, modifier = modifier, badges = { if (membership.hasLabel()) { @@ -54,7 +54,7 @@ fun UserLabel( data class UserInfoLabel( val labelName: String, - val isLegalHold: Boolean, + val showLegalHoldIndicator: Boolean, val membership: Membership, val unavailable: Boolean = false, val proteusVerificationStatus: Conversation.VerificationStatus? = null, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt index 467805827d1..aefae1efbe4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt @@ -33,11 +33,12 @@ import com.wire.kalium.logic.data.user.type.isTeammate sealed class ConversationItem : ConversationFolderItem { abstract val conversationId: ConversationId abstract val mutedStatus: MutedConversationStatus - abstract val isLegalHold: Boolean + abstract val showLegalHoldIndicator: Boolean abstract val lastMessageContent: UILastMessageContent? abstract val badgeEventType: BadgeEventType abstract val teamId: TeamId? abstract val isArchived: Boolean + abstract val isFavorite: Boolean abstract val mlsVerificationStatus: Conversation.VerificationStatus abstract val proteusVerificationStatus: Conversation.VerificationStatus abstract val hasNewActivitiesToShow: Boolean @@ -48,16 +49,17 @@ sealed class ConversationItem : ConversationFolderItem { data class GroupConversation( val groupName: String, val hasOnGoingCall: Boolean = false, - val isSelfUserCreator: Boolean = false, val selfMemberRole: Conversation.Member.Role?, + val isFromTheSameTeam: Boolean, val isSelfUserMember: Boolean = true, override val conversationId: ConversationId, override val mutedStatus: MutedConversationStatus, - override val isLegalHold: Boolean = false, + override val showLegalHoldIndicator: Boolean = false, override val lastMessageContent: UILastMessageContent?, override val badgeEventType: BadgeEventType, override val teamId: TeamId?, override val isArchived: Boolean, + override val isFavorite: Boolean, override val mlsVerificationStatus: Conversation.VerificationStatus, override val proteusVerificationStatus: Conversation.VerificationStatus, override val hasNewActivitiesToShow: Boolean = false, @@ -71,11 +73,12 @@ sealed class ConversationItem : ConversationFolderItem { val blockingState: BlockingState, override val conversationId: ConversationId, override val mutedStatus: MutedConversationStatus, - override val isLegalHold: Boolean = false, + override val showLegalHoldIndicator: Boolean = false, override val lastMessageContent: UILastMessageContent?, override val badgeEventType: BadgeEventType, override val teamId: TeamId?, override val isArchived: Boolean, + override val isFavorite: Boolean, override val mlsVerificationStatus: Conversation.VerificationStatus, override val proteusVerificationStatus: Conversation.VerificationStatus, override val hasNewActivitiesToShow: Boolean = false, @@ -87,10 +90,11 @@ sealed class ConversationItem : ConversationFolderItem { val conversationInfo: ConversationInfo, override val conversationId: ConversationId, override val mutedStatus: MutedConversationStatus, - override val isLegalHold: Boolean = false, + override val showLegalHoldIndicator: Boolean = false, override val lastMessageContent: UILastMessageContent?, override val badgeEventType: BadgeEventType, override val isArchived: Boolean = false, + override val isFavorite: Boolean = false, override val hasNewActivitiesToShow: Boolean = false, override val searchQuery: String = "", ) : ConversationItem() { @@ -123,7 +127,7 @@ val OtherUser.BlockState: BlockingState fun ConversationItem.PrivateConversation.toUserInfoLabel() = UserInfoLabel( labelName = conversationInfo.name, - isLegalHold = isLegalHold, + showLegalHoldIndicator = showLegalHoldIndicator, membership = conversationInfo.membership, unavailable = conversationInfo.isSenderUnavailable, mlsVerificationStatus = mlsVerificationStatus, @@ -133,7 +137,7 @@ fun ConversationItem.PrivateConversation.toUserInfoLabel() = fun ConversationItem.ConnectionConversation.toUserInfoLabel() = UserInfoLabel( labelName = conversationInfo.name, - isLegalHold = isLegalHold, + showLegalHoldIndicator = showLegalHoldIndicator, membership = conversationInfo.membership, unavailable = conversationInfo.isSenderUnavailable ) 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 6cc8a51f698..39fc2d15e6f 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 @@ -172,7 +172,7 @@ fun EnabledMessageComposer( membersToMention = messageComposerViewState.value.mentionSearchResult, searchQuery = messageComposerViewState.value.mentionSearchQuery, onMentionPicked = { pickedMention -> - messageCompositionHolder.addMention(pickedMention) + messageCompositionHolder.value.addMention(pickedMention) onClearMentionSearchResult() }, modifier = Modifier.align(Alignment.BottomCenter) @@ -205,14 +205,18 @@ fun EnabledMessageComposer( ActiveMessageComposerInput( conversationId = conversationId, messageComposition = messageComposition.value, - messageTextState = inputStateHolder.messageTextState, + messageTextFieldValue = inputStateHolder.messageTextFieldValue, + onValueChange = { + inputStateHolder.messageTextFieldValue.value = it + }, + mentions = (messageComposition.value.selectedMentions), isTextExpanded = inputStateHolder.isTextExpanded, inputType = messageCompositionInputStateHolder.inputType, focusRequester = messageCompositionInputStateHolder.focusRequester, onFocused = ::onInputFocused, onToggleInputSize = messageCompositionInputStateHolder::toggleInputSize, onTextCollapse = messageCompositionInputStateHolder::collapseText, - onCancelReply = messageCompositionHolder::clearReply, + onCancelReply = messageCompositionHolder.value::clearReply, onCancelEdit = ::cancelEdit, onChangeSelfDeletionClicked = onChangeSelfDeletionClicked, onSendButtonClicked = onSendButtonClicked, @@ -274,7 +278,7 @@ fun EnabledMessageComposer( membersToMention = mentionSearchResult, searchQuery = messageComposerViewState.value.mentionSearchQuery, onMentionPicked = { - messageCompositionHolder.addMention(it) + messageCompositionHolder.value.addMention(it) onClearMentionSearchResult() } ) @@ -289,9 +293,9 @@ fun EnabledMessageComposer( attachmentsVisible = inputStateHolder.optionsVisible, isEditing = messageCompositionInputStateHolder.inputType is InputType.Editing, isMentionActive = messageComposerViewState.value.mentionSearchResult.isNotEmpty(), - onMentionButtonClicked = messageCompositionHolder::startMention, + onMentionButtonClicked = messageCompositionHolder.value::startMention, onOnSelfDeletingOptionClicked = onChangeSelfDeletionClicked, - onRichOptionButtonClicked = messageCompositionHolder::addOrRemoveMessageMarkdown, + onRichOptionButtonClicked = messageCompositionHolder.value::addOrRemoveMessageMarkdown, onPingOptionClicked = onPingOptionClicked, onAdditionalOptionsMenuClicked = { if (!hideRipple) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt index d122ba6c584..d5336197aea 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt @@ -26,10 +26,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.wire.android.model.Clickable import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.home.conversations.mention.MemberItemToMention import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.home.newconversation.model.Contact +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme +import com.wire.kalium.logic.data.user.ConnectionState @Composable fun MembersMentionList( @@ -66,3 +69,35 @@ fun MembersMentionList( } } } + +@MultipleThemePreviews +@Composable +fun MembersMentionListPreview() { + WireTheme { + MembersMentionList( + membersToMention = listOf( + Contact( + id = "1", + domain = "domain", + name = "Marko Alonso", + handle = "john.doe", + label = "label", + membership = Membership.Admin, + connectionState = ConnectionState.ACCEPTED + ), + Contact( + id = "2", + domain = "domain", + name = "John Doe", + handle = "john.doe", + label = "label", + membership = Membership.Admin, + connectionState = ConnectionState.ACCEPTED + ) + ), + searchQuery = "John", + onMentionPicked = {}, + modifier = Modifier + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt index 92abe5a72e5..e16bda6fe63 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -48,6 +47,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.R import com.wire.android.ui.common.TextWithLearnMore import com.wire.android.ui.common.banner.SecurityClassificationBannerForConversation @@ -134,10 +134,10 @@ fun MessageComposer( messageComposerStateHolder = messageComposerStateHolder, messageListContent = messageListContent, onSendButtonClicked = { - onSendMessageBundle(messageCompositionHolder.toMessageBundle(conversationId)) + onSendMessageBundle(messageCompositionHolder.value.toMessageBundle(conversationId)) onClearMentionSearchResult() clearMessage() - messageCompositionHolder.onClearDraft() + messageCompositionHolder.value.onClearDraft() }, onPingOptionClicked = onPingOptionClicked, onImagesPicked = onImagesPicked, @@ -249,29 +249,36 @@ private fun BaseComposerPreview( ) ) } - val messageTextState = rememberTextFieldState() + + val messageTextFieldValue = remember { mutableStateOf(TextFieldValue()) } + val messageComposition = remember { mutableStateOf(MessageComposition(ConversationId("value", "domain"))) } val keyboardController = LocalSoftwareKeyboardController.current val focusRequester = remember { FocusRequester() } + val messageCompositionHolder = remember { + mutableStateOf( + MessageCompositionHolder( + messageComposition = messageComposition, + messageTextFieldValue = messageTextFieldValue, + onClearDraft = {}, + onSaveDraft = {}, + onSearchMentionQueryChanged = {}, + onClearMentionSearchResult = {}, + onTypingEvent = {} + ) + ) + } MessageComposer( conversationId = ConversationId("value", "domain"), bottomSheetVisible = false, messageComposerStateHolder = MessageComposerStateHolder( messageComposerViewState = messageComposerViewState, messageCompositionInputStateHolder = MessageCompositionInputStateHolder( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = keyboardController, focusRequester = focusRequester ), - messageCompositionHolder = MessageCompositionHolder( - messageComposition = messageComposition, - messageTextState = messageTextState, - onClearDraft = {}, - onSaveDraft = {}, - onSearchMentionQueryChanged = {}, - onClearMentionSearchResult = {}, - onTypingEvent = {} - ), + messageCompositionHolder = messageCompositionHolder, additionalOptionStateHolder = AdditionalOptionStateHolder(), ), onPingOptionClicked = { }, @@ -288,6 +295,12 @@ private fun BaseComposerPreview( ) } +@PreviewMultipleThemes +@Composable +private fun PreviewMessageComposerEnabled() = WireTheme { + BaseComposerPreview(interactionAvailability = InteractionAvailability.ENABLED) +} + @PreviewMultipleThemes @Composable private fun PreviewMessageComposerDeletedUser() = WireTheme { @@ -311,9 +324,3 @@ private fun PreviewMessageComposerUnsupportedProtocol() = WireTheme { private fun PreviewMessageComposerLegalHold() = WireTheme { BaseComposerPreview(interactionAvailability = InteractionAvailability.LEGAL_HOLD) } - -@PreviewMultipleThemes -@Composable -private fun PreviewMessageComposerEnabled() = WireTheme { - BaseComposerPreview(interactionAvailability = InteractionAvailability.ENABLED) -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt index 48e30853d6f..e9c2b9012bf 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt @@ -29,16 +29,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -53,6 +52,7 @@ import androidx.compose.ui.input.key.onPreInterceptKeyBeforeSoftKeyboard import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension @@ -68,6 +68,7 @@ import com.wire.android.ui.common.textfield.WireTextFieldColors import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.home.conversations.UsersTypingIndicatorForConversation import com.wire.android.ui.home.conversations.messages.QuotedMessagePreview +import com.wire.android.ui.home.conversations.model.UIMention import com.wire.android.ui.home.messagecomposer.actions.SelfDeletingMessageActionArgs import com.wire.android.ui.home.messagecomposer.actions.SelfDeletingMessageActionViewModel import com.wire.android.ui.home.messagecomposer.actions.SelfDeletingMessageActionViewModelImpl @@ -85,7 +86,9 @@ import com.wire.kalium.logic.data.message.SelfDeletionTimer fun ActiveMessageComposerInput( conversationId: ConversationId, messageComposition: MessageComposition, - messageTextState: TextFieldState, + messageTextFieldValue: State, + onValueChange: (TextFieldValue) -> Unit, + mentions: List, isTextExpanded: Boolean, inputType: InputType, focusRequester: FocusRequester, @@ -128,7 +131,9 @@ fun ActiveMessageComposerInput( InputContent( conversationId = conversationId, - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, + onValueChange = onValueChange, + mentions = mentions, isTextExpanded = isTextExpanded, inputType = inputType, focusRequester = focusRequester, @@ -165,7 +170,9 @@ fun ActiveMessageComposerInput( @Composable private fun InputContent( conversationId: ConversationId, - messageTextState: TextFieldState, + messageTextFieldValue: State, + onValueChange: (TextFieldValue) -> Unit, + mentions: List, isTextExpanded: Boolean, inputType: InputType, focusRequester: FocusRequester, @@ -208,7 +215,9 @@ private fun InputContent( isTextExpanded = isTextExpanded, focusRequester = focusRequester, colors = inputType.inputTextColor(isSelfDeleting = viewModel.state().duration != null), - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, + onValueChange = onValueChange, + mentions = mentions, placeHolderText = viewModel.state().duration?.let { stringResource(id = R.string.self_deleting_message_label) } ?: inputType.labelText(), onFocused = onFocused, @@ -265,10 +274,12 @@ private fun InputContent( @OptIn(ExperimentalComposeUiApi::class) @Composable private fun MessageComposerTextInput( + mentions: List, isTextExpanded: Boolean, focusRequester: FocusRequester, colors: WireTextFieldColors, - messageTextState: TextFieldState, + messageTextFieldValue: State, + onValueChange: (TextFieldValue) -> Unit, placeHolderText: String, onTextCollapse: () -> Unit, onFocused: () -> Unit, @@ -286,9 +297,10 @@ private fun MessageComposerTextInput( } WireTextField( - textState = messageTextState, + textFieldValue = messageTextFieldValue, + onValueChange = onValueChange, + mentions = mentions, colors = colors, - lineLimits = TextFieldLineLimits.MultiLine(), textStyle = MaterialTheme.wireTypography.body01, // Add an extra space so that the cursor is placed one space before "Type a message" placeholderText = " $placeHolderText", @@ -354,7 +366,9 @@ private fun PreviewActiveMessageComposerInput(inputType: InputType, isTextExpand ActiveMessageComposerInput( conversationId = ConversationId("conversationId", "domain"), messageComposition = MessageComposition(ConversationId("conversationId", "domain")), - messageTextState = rememberTextFieldState("abc"), + mentions = emptyList(), + messageTextFieldValue = remember { mutableStateOf(TextFieldValue()) }, + onValueChange = {}, isTextExpanded = isTextExpanded, inputType = inputType, focusRequester = FocusRequester(), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt index 292b63b77c1..71e2a9fc5ca 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt @@ -48,6 +48,7 @@ import okio.buffer import java.io.File import java.io.FileInputStream import java.io.IOException +import java.io.RandomAccessFile import java.nio.ByteBuffer import java.nio.ByteOrder import javax.inject.Inject @@ -121,19 +122,21 @@ class AudioMediaRecorder @Inject constructor( val blockAlign = channels * (bitsPerSample / BITS_PER_BYTE) // We use buffer() to correctly write the string values. - bufferedSink.writeUtf8(CHUNK_ID_RIFF) // Chunk ID - bufferedSink.writeIntLe(PLACEHOLDER_SIZE) // Placeholder for Chunk Size (will be updated later) - bufferedSink.writeUtf8(FORMAT_WAVE) // Format - bufferedSink.writeUtf8(SUBCHUNK1_ID_FMT) // Subchunk1 ID - bufferedSink.writeIntLe(SUBCHUNK1_SIZE_PCM) // Subchunk1 Size (PCM) - bufferedSink.writeShortLe(AUDIO_FORMAT_PCM) // Audio Format (PCM) - bufferedSink.writeShortLe(channels) // Number of Channels - bufferedSink.writeIntLe(sampleRate) // Sample Rate - bufferedSink.writeIntLe(byteRate) // Byte Rate - bufferedSink.writeShortLe(blockAlign) // Block Align - bufferedSink.writeShortLe(bitsPerSample) // Bits Per Sample - bufferedSink.writeUtf8(SUBCHUNK2_ID_DATA) // Subchunk2 ID - bufferedSink.writeIntLe(PLACEHOLDER_SIZE) // Placeholder for Subchunk2 Size (will be updated later) + with(bufferedSink) { + writeUtf8(CHUNK_ID_RIFF) // Chunk ID + writeIntLe(PLACEHOLDER_SIZE) // Placeholder for Chunk Size (will be updated later) + writeUtf8(FORMAT_WAVE) // Format + writeUtf8(SUBCHUNK1_ID_FMT) // Subchunk1 ID + writeIntLe(SUBCHUNK1_SIZE_PCM) // Subchunk1 Size (PCM) + writeShortLe(AUDIO_FORMAT_PCM) // Audio Format (PCM) + writeShortLe(channels) // Number of Channels + writeIntLe(sampleRate) // Sample Rate + writeIntLe(byteRate) // Byte Rate + writeShortLe(blockAlign) // Block Align + writeShortLe(bitsPerSample) // Bits Per Sample + writeUtf8(SUBCHUNK2_ID_DATA) // Subchunk2 ID + writeIntLe(PLACEHOLDER_SIZE) // Placeholder for Subchunk2 Size (will be updated later) + } } private fun updateWavHeader(filePath: Path) { @@ -149,17 +152,14 @@ class AudioMediaRecorder @Inject constructor( dataSizeBuffer.order(ByteOrder.LITTLE_ENDIAN) dataSizeBuffer.putInt(dataSize) - val randomAccessFile = java.io.RandomAccessFile(file, "rw") - - // Update Chunk Size - randomAccessFile.seek(CHUNK_SIZE_OFFSET.toLong()) - randomAccessFile.write(chunkSizeBuffer.array()) - - // Update Subchunk2 Size - randomAccessFile.seek(SUBCHUNK2_SIZE_OFFSET.toLong()) - randomAccessFile.write(dataSizeBuffer.array()) - - randomAccessFile.close() + RandomAccessFile(file, "rw").use { randomAccessFile -> + // Update Chunk Size + randomAccessFile.seek(CHUNK_SIZE_OFFSET.toLong()) + randomAccessFile.write(chunkSizeBuffer.array()) + // Update Subchunk2 Size + randomAccessFile.seek(SUBCHUNK2_SIZE_OFFSET.toLong()) + randomAccessFile.write(dataSizeBuffer.array()) + } appLogger.i("Updated WAV Header: Chunk Size = ${fileSize - CHUNK_ID_SIZE}, Data Size = $dataSize") } @@ -175,48 +175,41 @@ class AudioMediaRecorder @Inject constructor( audioRecorder = null } + @Suppress("NestedBlockDepth") private fun writeAudioDataToFile() { val data = ByteArray(BUFFER_SIZE) - var sink: okio.Sink? = null try { - sink = kaliumFileSystem.sink(originalOutputPath!!) - val bufferedSink = sink.buffer() - - // Write WAV header - writeWavHeader(bufferedSink, SAMPLING_RATE, AUDIO_CHANNELS, BITS_PER_SAMPLE) - - while (isRecording) { - val read = audioRecorder?.read(data, 0, BUFFER_SIZE) ?: 0 - if (read > 0) { - bufferedSink.write(data, 0, read) - } + kaliumFileSystem.sink(originalOutputPath!!).use { sink -> + sink.buffer() + .use { + writeWavHeader(it, SAMPLING_RATE, AUDIO_CHANNELS, BITS_PER_SAMPLE) + while (isRecording) { + val read = audioRecorder?.read(data, 0, BUFFER_SIZE) ?: 0 + if (read > 0) { + it.write(data, 0, read) + } - // Check if the file size exceeds the limit - val currentSize = originalOutputPath!!.toFile().length() - if (currentSize > (assetLimitInMB * SIZE_OF_1MB)) { - isRecording = false - scope.launch { - _maxFileSizeReached.emit( - RecordAudioDialogState.MaxFileSizeReached( - maxSize = assetLimitInMB / SIZE_OF_1MB - ) - ) + // Check if the file size exceeds the limit + val currentSize = originalOutputPath!!.toFile().length() + if (currentSize > (assetLimitInMB * SIZE_OF_1MB)) { + isRecording = false + scope.launch { + _maxFileSizeReached.emit( + RecordAudioDialogState.MaxFileSizeReached( + maxSize = assetLimitInMB / SIZE_OF_1MB + ) + ) + } + break + } + } + updateWavHeader(originalOutputPath!!) } - break - } } - - // Close buffer to ensure all data is written - bufferedSink.close() - - // Update WAV header with final file size - updateWavHeader(originalOutputPath!!) } catch (e: IOException) { e.printStackTrace() appLogger.e("[RecordAudio] writeAudioDataToFile: IOException - ${e.message}") - } finally { - sink?.close() } } @@ -224,145 +217,139 @@ class AudioMediaRecorder @Inject constructor( suspend fun convertWavToMp4(inputFilePath: String): Boolean = withContext(Dispatchers.IO) { var codec: MediaCodec? = null var muxer: MediaMuxer? = null - var fileInputStream: FileInputStream? = null - var parcelFileDescriptor: ParcelFileDescriptor? = null var success = true try { - val inputFile = File(inputFilePath) - fileInputStream = FileInputStream(inputFile) - - val outputFile = mp4OutputPath?.toFile() - parcelFileDescriptor = ParcelFileDescriptor.open( - outputFile, - ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE - ) - - val mediaExtractor = MediaExtractor() - mediaExtractor.setDataSource(inputFilePath) - - val format = MediaFormat.createAudioFormat( - MediaFormat.MIMETYPE_AUDIO_AAC, - SAMPLING_RATE, - AUDIO_CHANNELS - ) - format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE) - format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC) - - codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC) - codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) - codec.start() - - val bufferInfo = MediaCodec.BufferInfo() - muxer = MediaMuxer(parcelFileDescriptor.fileDescriptor, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) - var trackIndex = -1 - var sawInputEOS = false - var sawOutputEOS = false - - var retryCount = 0 - var presentationTimeUs = 0L - val bytesPerSample = (BITS_PER_SAMPLE / BITS_PER_BYTE) * AUDIO_CHANNELS - - while (!sawOutputEOS && retryCount < MAX_RETRY_COUNT) { - if (!sawInputEOS) { - val inputBufferIndex = codec.dequeueInputBuffer(TIMEOUT_US) - if (inputBufferIndex >= 0) { - val inputBuffer = codec.getInputBuffer(inputBufferIndex) - inputBuffer?.clear() - - val sampleSize = fileInputStream.channel.read(inputBuffer!!) - if (sampleSize < 0) { - codec.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) - sawInputEOS = true - } else { - val numSamples = sampleSize / bytesPerSample - val bufferDurationUs = (numSamples * MICROSECONDS_PER_SECOND) / SAMPLING_RATE - codec.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0) - - presentationTimeUs += bufferDurationUs - } - } - } - - val outputBufferIndex = codec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) - - when { - outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { - val newFormat = codec.outputFormat - trackIndex = muxer.addTrack(newFormat) - muxer.start() - retryCount = 0 - } - - outputBufferIndex >= 0 -> { - val outputBuffer = codec.getOutputBuffer(outputBufferIndex) - - if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { - bufferInfo.size = 0 - } - - if (bufferInfo.size != 0 && outputBuffer != null) { - outputBuffer.position(bufferInfo.offset) - outputBuffer.limit(bufferInfo.offset + bufferInfo.size) + FileInputStream(File(inputFilePath)).use { fileInputStream -> + mp4OutputPath?.toFile()?.let { outputFile -> + ParcelFileDescriptor.open( + outputFile, + ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE + ).use { parcelFileDescriptor -> + + val mediaExtractor = MediaExtractor() + mediaExtractor.setDataSource(inputFilePath) + + val format = MediaFormat.createAudioFormat( + MediaFormat.MIMETYPE_AUDIO_AAC, + SAMPLING_RATE, + AUDIO_CHANNELS + ) + format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE) + format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC) + + codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC) + val mediaCodec = codec!! + mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + mediaCodec.start() + + val bufferInfo = MediaCodec.BufferInfo() + muxer = MediaMuxer(parcelFileDescriptor.fileDescriptor, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + val mediaMuxer = muxer!! + + var trackIndex = -1 + var sawInputEOS = false + var sawOutputEOS = false + + var retryCount = 0 + var presentationTimeUs = 0L + val bytesPerSample = (BITS_PER_SAMPLE / BITS_PER_BYTE) * AUDIO_CHANNELS + + while (!sawOutputEOS && retryCount < MAX_RETRY_COUNT) { + if (!sawInputEOS) { + val inputBufferIndex = mediaCodec.dequeueInputBuffer(TIMEOUT_US) + if (inputBufferIndex >= 0) { + val inputBuffer = mediaCodec.getInputBuffer(inputBufferIndex) + inputBuffer?.clear() + + val sampleSize = fileInputStream.channel.read(inputBuffer!!) + if (sampleSize < 0) { + mediaCodec.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + sawInputEOS = true + } else { + val numSamples = sampleSize / bytesPerSample + val bufferDurationUs = (numSamples * MICROSECONDS_PER_SECOND) / SAMPLING_RATE + mediaCodec.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0) + + presentationTimeUs += bufferDurationUs + } + } + } - if (trackIndex >= 0) { - muxer.writeSampleData(trackIndex, outputBuffer, bufferInfo) - } else { - appLogger.e("Track index is not set. Skipping writeSampleData.") + val outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) + + when { + outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + val newFormat = mediaCodec.outputFormat + trackIndex = mediaMuxer.addTrack(newFormat) + mediaMuxer.start() + retryCount = 0 + } + + outputBufferIndex >= 0 -> { + val outputBuffer = mediaCodec.getOutputBuffer(outputBufferIndex) + + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + bufferInfo.size = 0 + } + + if (bufferInfo.size != 0 && outputBuffer != null) { + outputBuffer.position(bufferInfo.offset) + outputBuffer.limit(bufferInfo.offset + bufferInfo.size) + + if (trackIndex >= 0) { + mediaMuxer.writeSampleData(trackIndex, outputBuffer, bufferInfo) + } else { + appLogger.e("Track index is not set. Skipping writeSampleData.") + } + } + + mediaCodec.releaseOutputBuffer(outputBufferIndex, false) + retryCount = 0 + + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + sawOutputEOS = true + } + } + + outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> { + retryCount++ + delay(RETRY_DELAY_IN_MILLIS) + } } } - - codec.releaseOutputBuffer(outputBufferIndex, false) - retryCount = 0 - - if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { - sawOutputEOS = true + if (retryCount >= MAX_RETRY_COUNT) { + appLogger.e("Reached maximum retries without receiving output from codec.") + success = false } } - - outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> { - retryCount++ - delay(RETRY_DELAY_IN_MILLIS) - } + } ?: run { + appLogger.e("[RecordAudio] convertWavToMp4: mp4OutputPath is null") + success = false } } - if (retryCount >= MAX_RETRY_COUNT) { - appLogger.e("Reached maximum retries without receiving output from codec.") - success = false - } } catch (e: Exception) { appLogger.e("Could not convert wav to mp4: ${e.message}", throwable = e) - success = false } finally { try { - fileInputStream?.close() - } catch (e: Exception) { - appLogger.e("Could not close FileInputStream: ${e.message}", throwable = e) - success = false - } - - try { - muxer?.stop() - muxer?.release() + muxer?.let { safeMuxer -> + safeMuxer.stop() + safeMuxer.release() + } } catch (e: Exception) { appLogger.e("Could not stop or release MediaMuxer: ${e.message}", throwable = e) success = false } try { - codec?.stop() - codec?.release() + codec?.let { safeCodec -> + safeCodec.stop() + safeCodec.release() + } } catch (e: Exception) { appLogger.e("Could not stop or release MediaCodec: ${e.message}", throwable = e) success = false } - - try { - parcelFileDescriptor?.close() - } catch (e: Exception) { - appLogger.e("Could not close ParcelFileDescriptor: ${e.message}", throwable = e) - success = false - } } success } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt index 771e8e77138..0ea6534dc7d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt @@ -18,8 +18,6 @@ package com.wire.android.ui.home.messagecomposer.state -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State @@ -29,6 +27,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.ui.home.conversations.MessageComposerViewState import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.home.messagecomposer.model.MessageComposition @@ -52,26 +52,33 @@ fun rememberMessageComposerStateHolder( val messageComposition = remember(draftMessageComposition) { mutableStateOf(draftMessageComposition) } - val messageTextState = rememberTextFieldState() + + val messageTextFieldValue = remember { mutableStateOf(TextFieldValue()) } + LaunchedEffect(draftMessageComposition.draftText) { if (draftMessageComposition.draftText.isNotBlank()) { - messageTextState.setTextAndPlaceCursorAtEnd(draftMessageComposition.draftText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = draftMessageComposition.draftText, + selection = TextRange(draftMessageComposition.draftText.length) // Place cursor at the end of the new text + ) } } val messageCompositionHolder = remember { - MessageCompositionHolder( - messageComposition = messageComposition, - messageTextState = messageTextState, - onClearDraft = onClearDraft, - onSaveDraft = onSaveDraft, - onSearchMentionQueryChanged = onSearchMentionQueryChanged, - onClearMentionSearchResult = onClearMentionSearchResult, - onTypingEvent = onTypingEvent, + mutableStateOf( + MessageCompositionHolder( + messageComposition = messageComposition, + messageTextFieldValue = messageTextFieldValue, + onClearDraft = onClearDraft, + onSaveDraft = onSaveDraft, + onSearchMentionQueryChanged = onSearchMentionQueryChanged, + onClearMentionSearchResult = onClearMentionSearchResult, + onTypingEvent = onTypingEvent, + ) ) } LaunchedEffect(Unit) { - messageCompositionHolder.handleMessageTextUpdates() + messageCompositionHolder.value.handleMessageTextUpdates() } val keyboardController = LocalSoftwareKeyboardController.current val focusRequester = remember { @@ -80,14 +87,14 @@ fun rememberMessageComposerStateHolder( val messageCompositionInputStateHolder = rememberSaveable( saver = MessageCompositionInputStateHolder.saver( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = keyboardController, focusRequester = focusRequester, density = density ) ) { MessageCompositionInputStateHolder( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = keyboardController, focusRequester = focusRequester ) @@ -116,18 +123,18 @@ fun rememberMessageComposerStateHolder( class MessageComposerStateHolder( val messageComposerViewState: State, val messageCompositionInputStateHolder: MessageCompositionInputStateHolder, - val messageCompositionHolder: MessageCompositionHolder, + val messageCompositionHolder: State, val additionalOptionStateHolder: AdditionalOptionStateHolder, ) { - val messageComposition = messageCompositionHolder.messageComposition + val messageComposition = messageCompositionHolder.value.messageComposition fun toEdit(messageId: String, editMessageText: String, mentions: List) { - messageCompositionHolder.setEditText(messageId, editMessageText, mentions) + messageCompositionHolder.value.setEditText(messageId, editMessageText, mentions) messageCompositionInputStateHolder.toEdit(editMessageText) } fun toReply(message: UIMessage.Regular) { - messageCompositionHolder.setReply(message) + messageCompositionHolder.value.setReply(message) messageCompositionInputStateHolder.toComposing() } @@ -146,7 +153,7 @@ class MessageComposerStateHolder( fun cancelEdit() { messageCompositionInputStateHolder.toComposing() - messageCompositionHolder.clearMessage() + messageCompositionHolder.value.clearMessage() } fun showAttachments(showOptions: Boolean) { @@ -154,6 +161,6 @@ class MessageComposerStateHolder( } fun clearMessage() { - messageCompositionHolder.clearMessage() + messageCompositionHolder.value.clearMessage() } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt index 3d70862f77f..18059202bb4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt @@ -17,12 +17,10 @@ */ package com.wire.android.ui.home.messagecomposer.state -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.clearText -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.MutableState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.substring import com.wire.android.ui.home.conversations.model.UIMention import com.wire.android.ui.home.conversations.model.UIMessage @@ -53,7 +51,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged @Suppress("TooManyFunctions") class MessageCompositionHolder( val messageComposition: MutableState, - val messageTextState: TextFieldState, + var messageTextFieldValue: MutableState, val onClearDraft: () -> Unit, private val onSaveDraft: (MessageDraft) -> Unit, private val onSearchMentionQueryChanged: (String) -> Unit, @@ -72,7 +70,7 @@ class MessageCompositionHolder( editMessageId = null ) } - onSaveDraft(messageComposition.value.toDraft(messageTextState.text.toString())) + onSaveDraft(messageComposition.value.toDraft(messageTextFieldValue.value.text)) } fun setReply(message: UIMessage.Regular) { @@ -96,7 +94,7 @@ class MessageCompositionHolder( ) } } - onSaveDraft(messageComposition.value.toDraft(messageTextState.text.toString())) + onSaveDraft(messageComposition.value.toDraft(messageTextFieldValue.value.text)) } fun clearReply() { @@ -110,13 +108,13 @@ class MessageCompositionHolder( } suspend fun handleMessageTextUpdates() { - snapshotFlow { messageTextState.text to messageTextState.selection } + snapshotFlow { messageTextFieldValue.value.text to messageTextFieldValue.value.selection } .distinctUntilChanged() .collectLatest { (messageText, selection) -> - updateTypingEvent(messageText.toString()) - updateMentionsIfNeeded(messageText.toString()) - requestMentionSuggestionIfNeeded(messageText.toString(), selection) - onSaveDraft(messageComposition.value.toDraft(messageText.toString())) + updateTypingEvent(messageText) + updateMentionsIfNeeded(messageText) + requestMentionSuggestionIfNeeded(messageText, selection) + onSaveDraft(messageComposition.value.toDraft(messageText)) } } @@ -162,8 +160,8 @@ class MessageCompositionHolder( } fun startMention() { - val beforeSelection = messageTextState.text - .subSequence(0, messageTextState.selection.min) + val beforeSelection = messageTextFieldValue.value.text + .subSequence(0, messageTextFieldValue.value.selection.min) .run { if (endsWith(String.WHITE_SPACE) || endsWith(String.NEW_LINE_SYMBOL) || this == String.EMPTY) { this.toString() @@ -174,10 +172,10 @@ class MessageCompositionHolder( } } - val afterSelection = messageTextState.text + val afterSelection = messageTextFieldValue.value.text .subSequence( - messageTextState.selection.max, - messageTextState.text.length + messageTextFieldValue.value.selection.max, + messageTextFieldValue.value.text.length ) val resultText = StringBuilder(beforeSelection) @@ -186,16 +184,16 @@ class MessageCompositionHolder( .toString() val newSelection = TextRange(beforeSelection.length + 1) - messageTextState.edit { - replace(0, messageTextState.text.length, resultText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = messageTextFieldValue.value.text.replaceRange(0, messageTextFieldValue.value.text.length, resultText), selection = newSelection - } + ) requestMentionSuggestionIfNeeded(resultText, newSelection) } fun addMention(contact: Contact) { val mention = UIMention( - start = currentMentionStartIndex(messageTextState.text.toString(), messageTextState.selection), + start = currentMentionStartIndex(messageTextFieldValue.value.text, messageTextFieldValue.value.selection), length = contact.name.length + 1, // +1 cause there is an "@" before it userId = UserId(contact.id, contact.domain), handler = String.MENTION_SYMBOL + contact.name @@ -209,12 +207,12 @@ class MessageCompositionHolder( } private fun insertMentionIntoText(mention: UIMention) { - val beforeMentionText = messageTextState.text + val beforeMentionText = messageTextFieldValue.value.text .subSequence(0, mention.start) - val afterMentionText = messageTextState.text + val afterMentionText = messageTextFieldValue.value.text .subSequence( - messageTextState.selection.max, - messageTextState.text.length + messageTextFieldValue.value.selection.max, + messageTextFieldValue.value.text.length ) val resultText = StringBuilder() .append(beforeMentionText) @@ -227,16 +225,18 @@ class MessageCompositionHolder( // + 1 cause we add space after mention and move selector there val newSelection = TextRange(beforeMentionText.length + mention.handler.length + 1) - - messageTextState.edit { - replace(0, messageTextState.text.length, resultText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = messageTextFieldValue.value.text.replaceRange(0, messageTextFieldValue.value.text.length, resultText), selection = newSelection - } + ) onSaveDraft(messageComposition.value.toDraft(resultText)) } fun setEditText(messageId: String, editMessageText: String, mentions: List) { - messageTextState.setTextAndPlaceCursorAtEnd(editMessageText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = editMessageText, + selection = TextRange(editMessageText.length) // Place cursor at the end of the new text + ) messageComposition.update { it.copy( selectedMentions = mentions.mapNotNull { it.toUiMention(editMessageText) }, @@ -250,9 +250,9 @@ class MessageCompositionHolder( markdown: RichTextMarkdown, ) { val isHeader = markdown == RichTextMarkdown.Header - val range = messageTextState.selection - val selectedText = messageTextState.text.substring(messageTextState.selection) - val stringBuilder = StringBuilder(messageTextState.text.toString()) + val range = messageTextFieldValue.value.selection + val selectedText = messageTextFieldValue.value.text.substring(messageTextFieldValue.value.selection) + val stringBuilder = StringBuilder(messageTextFieldValue.value.text) val markdownLength = markdown.value.length val markdownLengthComplete = if (isHeader) markdownLength else (markdownLength * RICH_TEXT_MARKDOWN_MULTIPLIER) @@ -285,15 +285,15 @@ class MessageCompositionHolder( } val newMessageText = stringBuilder.toString() - messageTextState.edit { - replace(0, messageTextState.text.length, newMessageText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = messageTextFieldValue.value.text.replaceRange(0, messageTextFieldValue.value.text.length, newMessageText), selection = TextRange(selectionStart, selectionEnd) - } + ) onSaveDraft(messageComposition.value.toDraft(newMessageText)) } fun clearMessage() { - messageTextState.clearText() + messageTextFieldValue.value = TextFieldValue(String.EMPTY) messageComposition.update { it.copy( quotedMessageId = null, @@ -305,7 +305,7 @@ class MessageCompositionHolder( } fun toMessageBundle(conversationId: ConversationId) = - messageComposition.value.toMessageBundle(conversationId, messageTextState.text.toString()) + messageComposition.value.toMessageBundle(conversationId, messageTextFieldValue.value.text) private fun currentMentionStartIndex(messageText: String, selection: TextRange): Int { val lastIndexOfAt = messageText.lastIndexOf(String.MENTION_SYMBOL, selection.min - 1) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt index 8725f4bbf74..320655a133d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt @@ -18,8 +18,8 @@ package com.wire.android.ui.home.messagecomposer.state import androidx.annotation.VisibleForTesting -import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -30,6 +30,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -42,7 +43,7 @@ import com.wire.android.util.isNotMarkdownBlank @Stable class MessageCompositionInputStateHolder( - val messageTextState: TextFieldState, + val messageTextFieldValue: MutableState, private val keyboardController: SoftwareKeyboardController?, val focusRequester: FocusRequester ) { @@ -64,11 +65,12 @@ class MessageCompositionInputStateHolder( val inputType: InputType by derivedStateOf { when (val state = compositionState) { is CompositionState.Composing -> InputType.Composing( - isSendButtonEnabled = messageTextState.text.isNotMarkdownBlank() + isSendButtonEnabled = messageTextFieldValue.value.text.isNotMarkdownBlank() ) is CompositionState.Editing -> InputType.Editing( - isEditButtonEnabled = messageTextState.text != state.originalMessageText && messageTextState.text.isNotMarkdownBlank() + isEditButtonEnabled = messageTextFieldValue.value.text != state.originalMessageText && + messageTextFieldValue.value.text.isNotMarkdownBlank() ) } } @@ -170,7 +172,7 @@ class MessageCompositionInputStateHolder( val composeTextHeight = 128.dp fun saver( - messageTextState: TextFieldState, + messageTextFieldValue: MutableState, keyboardController: SoftwareKeyboardController?, focusRequester: FocusRequester, density: Density @@ -188,7 +190,7 @@ class MessageCompositionInputStateHolder( restore = { savedState -> with(density) { MessageCompositionInputStateHolder( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = keyboardController, focusRequester = focusRequester ).apply { diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt index c73a1155a1b..b3276eabcd0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt @@ -441,7 +441,8 @@ fun FileSharingRestrictedContent( annotation = learnMoreUrl, onClick = { CustomTabsHelper.launchUrl(context, learnMoreUrl) } ) - ) + ), + textColor = colorsScheme().onBackground ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileEventsHandlers.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileEventsHandlers.kt index 42bad51159b..162b14cee3c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileEventsHandlers.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileEventsHandlers.kt @@ -67,7 +67,6 @@ interface OtherUserProfileFooterEventsHandler { interface OtherUserProfileBottomSheetEventsHandler { fun onChangeMemberRole(role: Conversation.Member.Role) fun onMutingConversationStatusChange(conversationId: ConversationId?, status: MutedConversationStatus) - fun onAddConversationToFavourites(conversationId: ConversationId? = null) fun onMoveConversationToFolder(conversationId: ConversationId? = null) fun onMoveConversationToArchive(dialogState: DialogState) fun onClearConversationContent(dialogState: DialogState) @@ -77,7 +76,6 @@ interface OtherUserProfileBottomSheetEventsHandler { val PREVIEW = object : OtherUserProfileBottomSheetEventsHandler { override fun onChangeMemberRole(role: Conversation.Member.Role) {} override fun onMutingConversationStatusChange(conversationId: ConversationId?, status: MutedConversationStatus) {} - override fun onAddConversationToFavourites(conversationId: ConversationId?) {} override fun onMoveConversationToFolder(conversationId: ConversationId?) {} override fun onMoveConversationToArchive(dialogState: DialogState) {} override fun onClearConversationContent(dialogState: DialogState) {} diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt index b71d1d77019..98d5a77978d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt @@ -54,6 +54,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.RootNavGraph import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.R +import com.wire.android.di.hiltViewModelScoped import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator @@ -67,6 +68,9 @@ import com.wire.android.ui.common.VisibilityState import com.wire.android.ui.common.WireTabRow import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout import com.wire.android.ui.common.bottomsheet.WireModalSheetState +import com.wire.android.ui.common.bottomsheet.folder.ChangeConversationFavoriteStateArgs +import com.wire.android.ui.common.bottomsheet.folder.ChangeConversationFavoriteVM +import com.wire.android.ui.common.bottomsheet.folder.ChangeConversationFavoriteVMImpl import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.bottomsheet.show import com.wire.android.ui.common.button.WireButtonState @@ -104,6 +108,7 @@ import com.wire.android.ui.userprofile.group.RemoveConversationMemberState import com.wire.android.ui.userprofile.other.bottomsheet.OtherUserBottomSheetState import com.wire.android.ui.userprofile.other.bottomsheet.OtherUserProfileBottomSheetContent import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.ConnectionState @@ -202,6 +207,7 @@ fun OtherUserProfileScreen( snackbarHostState.showSnackbar(it.asString(context.resources)) } } + LaunchedEffect(Unit) { viewModel.closeBottomSheet.collect { sheetState.hide() @@ -237,7 +243,11 @@ fun OtherProfileScreenContent( onOpenDeviceDetails: (Device) -> Unit = {}, onConversationMediaClick: () -> Unit = {}, navigateBack: () -> Unit = {}, - onLegalHoldLearnMoreClick: () -> Unit = {} + onLegalHoldLearnMoreClick: () -> Unit = {}, + changeConversationFavoriteViewModel: ChangeConversationFavoriteVM = + hiltViewModelScoped( + ChangeConversationFavoriteStateArgs + ) ) { val otherUserProfileScreenState = rememberOtherUserProfileScreenState() val blockUserDialogState = rememberVisibilityState() @@ -273,6 +283,8 @@ fun OtherProfileScreenContent( }) } + SnackBarMessageHandler(changeConversationFavoriteViewModel.infoMessage, onEmitted = closeBottomSheet) + val tabItems by remember(state) { derivedStateOf { listOfNotNull( @@ -358,6 +370,7 @@ fun OtherProfileScreenContent( unblockUser = unblockUserDialogState::show, clearContent = clearConversationDialogState::show, archivingStatusState = archivingConversationDialogState::show, + changeFavoriteState = changeConversationFavoriteViewModel::changeFavoriteState, closeBottomSheet = closeBottomSheet, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt index f11a08c7030..c57fa29f6d7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt @@ -307,10 +307,6 @@ class OtherUserProfileScreenViewModel @Inject constructor( } } - @Suppress("EmptyFunctionBlock") - override fun onAddConversationToFavourites(conversationId: ConversationId?) { - } - @Suppress("EmptyFunctionBlock") override fun onMoveConversationToFolder(conversationId: ConversationId?) { } @@ -421,6 +417,7 @@ class OtherUserProfileScreenViewModel @Inject constructor( mlsVerificationStatus = conversation.mlsVerificationStatus, proteusVerificationStatus = conversation.proteusVerificationStatus, isUnderLegalHold = conversation.legalHoldStatus.showLegalHoldIndicator(), + isFavorite = null ) } ) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/bottomsheet/OtherUserProfileBottomSheet.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/bottomsheet/OtherUserProfileBottomSheet.kt index ae26a0e52a5..1757407daf3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/bottomsheet/OtherUserProfileBottomSheet.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/bottomsheet/OtherUserProfileBottomSheet.kt @@ -24,6 +24,7 @@ import com.wire.android.ui.common.bottomsheet.conversation.rememberConversationS import com.wire.android.ui.common.dialogs.BlockUserDialogState import com.wire.android.ui.common.dialogs.UnblockUserDialogState import com.wire.android.ui.home.conversationslist.model.DialogState +import com.wire.android.ui.home.conversationslist.model.GroupDialogState import com.wire.android.ui.userprofile.other.OtherUserProfileBottomSheetEventsHandler @Composable @@ -34,6 +35,7 @@ fun OtherUserProfileBottomSheetContent( archivingStatusState: (DialogState) -> Unit, blockUser: (BlockUserDialogState) -> Unit, unblockUser: (UnblockUserDialogState) -> Unit, + changeFavoriteState: (GroupDialogState, addToFavorite: Boolean) -> Unit, closeBottomSheet: () -> Unit, getBottomSheetVisibility: () -> Boolean ) { @@ -50,7 +52,7 @@ fun OtherUserProfileBottomSheetContent( mutedConversationStatus ) }, - addConversationToFavourites = eventsHandler::onAddConversationToFavourites, + changeFavoriteState = changeFavoriteState, moveConversationToFolder = eventsHandler::onMoveConversationToFolder, updateConversationArchiveStatus = { if (!it.isArchived) { diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt index 880408327af..b4e763d20c8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt @@ -203,7 +203,7 @@ private fun SelfQRCodeContent( color = colorsScheme().secondaryText ) Spacer(modifier = Modifier.weight(1f)) - ShareLinkButton(state.userProfileLink, trackAnalyticsEvent) + ShareLinkButton(state.userAccountProfileLink, trackAnalyticsEvent) VerticalSpace.x8() ShareQRCodeButton { trackAnalyticsEvent(AnalyticsEvent.QrCode.Modal.ShareQrCode) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/CreateTeamInfoCard.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/CreateTeamInfoCard.kt index f37355bfa39..3741472dd9d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/CreateTeamInfoCard.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/CreateTeamInfoCard.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material3.CardDefaults @@ -36,6 +35,7 @@ import com.wire.android.R import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes @@ -83,7 +83,6 @@ fun CreateTeamInfoCard( WireSecondaryButton( modifier = Modifier .padding(dimensions().spacing8x) - .width(dimensions().createTeamInfoCardButtonWidth) .height(dimensions().createTeamInfoCardButtonHeight), text = stringResource(R.string.user_profile_create_team_card_button), onClick = onCreateAccount, @@ -97,5 +96,7 @@ fun CreateTeamInfoCard( @PreviewMultipleThemes @Composable fun PreviewCreateTeamInfoCard() { - CreateTeamInfoCard({ }) + WireTheme { + CreateTeamInfoCard({ }) + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationScreen.kt index 42d921e53d3..c7ed3f3d9c5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -54,6 +55,10 @@ import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.userprofile.teammigration.common.ConfirmMigrationLeaveDialog +import com.wire.android.ui.userprofile.teammigration.step1.TEAM_MIGRATION_TEAM_PLAN_STEP +import com.wire.android.ui.userprofile.teammigration.step2.TEAM_MIGRATION_TEAM_NAME_STEP +import com.wire.android.ui.userprofile.teammigration.step3.TEAM_MIGRATION_CONFIRMATION_STEP +import com.wire.android.ui.userprofile.teammigration.step4.TEAM_MIGRATION_DONE_STEP @OptIn(ExperimentalMaterialNavigationApi::class, ExperimentalAnimationApi::class) @WireDestination(style = PopUpNavigationAnimation::class) @@ -82,6 +87,7 @@ fun TeamMigrationScreen( Column( modifier = modifier .padding(top = dimensions().spacing32x) + .navigationBarsPadding() .clip( shape = RoundedCornerShape( dimensions().corner16x, @@ -91,6 +97,14 @@ fun TeamMigrationScreen( .fillMaxSize() .background(color = colorsScheme().surface) ) { + val closeIconContentDescription = when (teamMigrationViewModel.teamMigrationState.currentStep) { + TEAM_MIGRATION_TEAM_PLAN_STEP -> stringResource(R.string.personal_to_team_migration_close_team_account_content_description) + TEAM_MIGRATION_TEAM_NAME_STEP -> stringResource(R.string.personal_to_team_migration_close_team_name_content_description) + TEAM_MIGRATION_CONFIRMATION_STEP -> stringResource(R.string.personal_to_team_migration_close_confirmation_content_description) + TEAM_MIGRATION_DONE_STEP -> stringResource(R.string.personal_to_team_migration_close_team_created_content_description) + else -> stringResource(R.string.personal_to_team_migration_close_icon_content_description) + } + IconButton( modifier = Modifier.align(alignment = Alignment.End), onClick = { @@ -105,7 +119,7 @@ fun TeamMigrationScreen( ) { Icon( painter = painterResource(id = R.drawable.ic_close), - contentDescription = stringResource(R.string.personal_to_team_migration_close_icon_content_description) + contentDescription = closeIconContentDescription ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationState.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationState.kt index 561ad8f57bd..3a045cfd045 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationState.kt @@ -18,8 +18,11 @@ package com.wire.android.ui.userprofile.teammigration import androidx.compose.foundation.text.input.TextFieldState +import com.wire.kalium.logic.CoreFailure data class TeamMigrationState( val teamNameTextState: TextFieldState = TextFieldState(), - val shouldShowMigrationLeaveDialog: Boolean = false + val shouldShowMigrationLeaveDialog: Boolean = false, + val currentStep: Int = 0, + val migrationFailure: CoreFailure? = null ) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModel.kt index 89494b1f5fc..b8652aa171b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModel.kt @@ -17,18 +17,25 @@ */ package com.wire.android.ui.userprofile.teammigration +import androidx.compose.foundation.text.input.setTextAndSelectAll import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.wire.android.feature.analytics.AnonymousAnalyticsManager import com.wire.android.feature.analytics.model.AnalyticsEvent +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.feature.user.migration.MigrateFromPersonalToTeamResult +import com.wire.kalium.logic.feature.user.migration.MigrateFromPersonalToTeamUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class TeamMigrationViewModel @Inject constructor( - private val anonymousAnalyticsManager: AnonymousAnalyticsManager + private val anonymousAnalyticsManager: AnonymousAnalyticsManager, + private val migrateFromPersonalToTeam: MigrateFromPersonalToTeamUseCase ) : ViewModel() { var teamMigrationState by mutableStateOf(TeamMigrationState()) @@ -56,6 +63,10 @@ class TeamMigrationViewModel @Inject constructor( ) } + fun setCurrentStep(step: Int) { + teamMigrationState = teamMigrationState.copy(currentStep = step) + } + fun sendPersonalTeamCreationFlowCanceledEvent( modalLeaveClicked: Boolean? = null, modalContinueClicked: Boolean? = null @@ -81,4 +92,31 @@ class TeamMigrationViewModel @Inject constructor( ) ) } + + fun migrateFromPersonalToTeamAccount(onSuccess: () -> Unit) { + viewModelScope.launch { + migrateFromPersonalToTeam.invoke( + teamMigrationState.teamNameTextState.text.toString(), + ).let { result -> + when (result) { + is MigrateFromPersonalToTeamResult.Success -> { + teamMigrationState.teamNameTextState.setTextAndSelectAll(result.teamName) + onSuccess() + } + + is MigrateFromPersonalToTeamResult.Error -> { + onMigrationFailure(result.failure) + } + } + } + } + } + + fun failureHandled() { + teamMigrationState = teamMigrationState.copy(migrationFailure = null) + } + + private fun onMigrationFailure(failure: CoreFailure) { + teamMigrationState = teamMigrationState.copy(migrationFailure = failure) + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/common/BottomLineButtons.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/common/BottomLineButtons.kt index 8b2fe3a4db9..65ef95d4bac 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/common/BottomLineButtons.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/common/BottomLineButtons.kt @@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import com.wire.android.R import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton @@ -41,6 +43,7 @@ fun BottomLineButtons( isContinueButtonEnabled: Boolean, modifier: Modifier = Modifier, isBackButtonVisible: Boolean = true, + backButtonContentDescription: String = stringResource(R.string.personal_to_team_migration_back_button_label), onBack: () -> Unit = { }, onContinue: () -> Unit = { } ) { @@ -60,7 +63,9 @@ fun BottomLineButtons( ) { if (isBackButtonVisible) { WireSecondaryButton( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics(true) { contentDescription = backButtonContentDescription }, text = stringResource(R.string.personal_to_team_migration_back_button_label), onClick = onBack ) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step1/TeamMigrationTeamPlanStepScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step1/TeamMigrationTeamPlanStepScreen.kt index f73d88d4029..7f34cda3a13 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step1/TeamMigrationTeamPlanStepScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step1/TeamMigrationTeamPlanStepScreen.kt @@ -54,12 +54,14 @@ import com.wire.android.ui.common.dimensions import com.wire.android.ui.destinations.TeamMigrationTeamNameStepScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography -import com.wire.android.ui.userprofile.teammigration.common.BottomLineButtons import com.wire.android.ui.userprofile.teammigration.PersonalToTeamMigrationNavGraph import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel +import com.wire.android.ui.userprofile.teammigration.common.BottomLineButtons import com.wire.android.util.CustomTabsHelper import com.wire.android.util.ui.PreviewMultipleThemes +const val TEAM_MIGRATION_TEAM_PLAN_STEP = 1 + @PersonalToTeamMigrationNavGraph(start = true) @WireDestination( style = SlideNavigationAnimation::class @@ -76,7 +78,8 @@ fun TeamMigrationTeamPlanStepScreen( ) LaunchedEffect(Unit) { - teamMigrationViewModel.sendPersonalTeamCreationFlowStartedEvent(1) + teamMigrationViewModel.sendPersonalTeamCreationFlowStartedEvent(TEAM_MIGRATION_TEAM_PLAN_STEP) + teamMigrationViewModel.setCurrentStep(TEAM_MIGRATION_TEAM_PLAN_STEP) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step2/TeamMigrationTeamNameStepScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step2/TeamMigrationTeamNameStepScreen.kt index 468c20d4f71..aa1e8a9ecf6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step2/TeamMigrationTeamNameStepScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step2/TeamMigrationTeamNameStepScreen.kt @@ -44,11 +44,13 @@ import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.destinations.TeamMigrationConfirmationStepScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography -import com.wire.android.ui.userprofile.teammigration.common.BottomLineButtons import com.wire.android.ui.userprofile.teammigration.PersonalToTeamMigrationNavGraph import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel +import com.wire.android.ui.userprofile.teammigration.common.BottomLineButtons import com.wire.android.util.ui.PreviewMultipleThemes +const val TEAM_MIGRATION_TEAM_NAME_STEP = 2 + @PersonalToTeamMigrationNavGraph @WireDestination( style = SlideNavigationAnimation::class @@ -68,7 +70,8 @@ fun TeamMigrationTeamNameStepScreen( teamNameTextFieldState = teamMigrationViewModel.teamMigrationState.teamNameTextState ) LaunchedEffect(Unit) { - teamMigrationViewModel.sendPersonalTeamCreationFlowStartedEvent(2) + teamMigrationViewModel.sendPersonalTeamCreationFlowStartedEvent(TEAM_MIGRATION_TEAM_NAME_STEP) + teamMigrationViewModel.setCurrentStep(TEAM_MIGRATION_TEAM_NAME_STEP) } } @@ -130,6 +133,7 @@ private fun TeamMigrationTeamNameStepScreenContent( BottomLineButtons( isContinueButtonEnabled = isContinueButtonEnabled, onContinue = onContinueButtonClicked, + backButtonContentDescription = stringResource(R.string.personal_to_team_migration_back_button_team_name_content_description), onBack = onBackButtonClicked ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt index 69fc9ca95e2..f3254d79370 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt @@ -47,16 +47,20 @@ import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.ui.common.WireCheckbox import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.error.CoreFailureErrorDialog import com.wire.android.ui.destinations.TeamMigrationDoneStepScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.android.ui.userprofile.teammigration.PersonalToTeamMigrationNavGraph +import com.wire.android.ui.userprofile.teammigration.TeamMigrationState import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel import com.wire.android.ui.userprofile.teammigration.common.BottomLineButtons import com.wire.android.ui.userprofile.teammigration.common.BulletList import com.wire.android.util.CustomTabsHelper import com.wire.android.util.ui.PreviewMultipleThemes +const val TEAM_MIGRATION_CONFIRMATION_STEP = 3 + @PersonalToTeamMigrationNavGraph @WireDestination( style = SlideNavigationAnimation::class @@ -66,21 +70,44 @@ fun TeamMigrationConfirmationStepScreen( navigator: DestinationsNavigator, teamMigrationViewModel: TeamMigrationViewModel ) { + val state = remember { teamMigrationViewModel.teamMigrationState } TeamMigrationConfirmationStepScreenContent( onContinueButtonClicked = { - // TODO: call the API to migrate the user to the team, if successful navigate to next screen - navigator.navigate(TeamMigrationDoneStepScreenDestination) + teamMigrationViewModel.migrateFromPersonalToTeamAccount( + onSuccess = { + navigator.navigate(TeamMigrationDoneStepScreenDestination) + }, + ) }, onBackPressed = { navigator.popBackStack() } ) + + HandleErrors(state, teamMigrationViewModel::failureHandled) + LaunchedEffect(Unit) { - teamMigrationViewModel.sendPersonalTeamCreationFlowStartedEvent(3) + teamMigrationViewModel.sendPersonalTeamCreationFlowStartedEvent(TEAM_MIGRATION_CONFIRMATION_STEP) + teamMigrationViewModel.setCurrentStep(TEAM_MIGRATION_CONFIRMATION_STEP) } } +@Composable +private fun HandleErrors( + teamMigrationState: TeamMigrationState, + onFailureHandled: () -> Unit +) { + val failure = teamMigrationState.migrationFailure ?: return + // TODO handle error WPB-14281 + CoreFailureErrorDialog( + coreFailure = failure, + onDialogDismiss = { + onFailureHandled() + } + ) +} + @Composable private fun TeamMigrationConfirmationStepScreenContent( modifier: Modifier = Modifier, @@ -161,6 +188,7 @@ private fun TeamMigrationConfirmationStepScreenContent( BottomLineButtons( isContinueButtonEnabled = isContinueButtonEnabled, onContinue = onContinueButtonClicked, + backButtonContentDescription = stringResource(R.string.personal_to_team_migration_back_button_confirmation_content_description), onBack = onBackPressed ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step4/TeamMigrationDoneStepScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step4/TeamMigrationDoneStepScreen.kt index cd2b4c3f1b9..2f582d61aa3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step4/TeamMigrationDoneStepScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step4/TeamMigrationDoneStepScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -45,12 +46,14 @@ import com.wire.android.ui.common.spacers.VerticalSpace.x32 import com.wire.android.ui.destinations.HomeScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography -import com.wire.android.ui.userprofile.teammigration.common.BulletList import com.wire.android.ui.userprofile.teammigration.PersonalToTeamMigrationNavGraph import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel +import com.wire.android.ui.userprofile.teammigration.common.BulletList import com.wire.android.util.CustomTabsHelper import com.wire.android.util.ui.PreviewMultipleThemes +const val TEAM_MIGRATION_DONE_STEP = 4 + @PersonalToTeamMigrationNavGraph @WireDestination( style = SlideNavigationAnimation::class @@ -84,6 +87,10 @@ fun TeamMigrationDoneStepScreen( teamName = teamMigrationViewModel.teamMigrationState.teamNameTextState.text.toString() ) + LaunchedEffect(Unit) { + teamMigrationViewModel.setCurrentStep(TEAM_MIGRATION_DONE_STEP) + } + BackHandler { } } diff --git a/app/src/main/kotlin/com/wire/android/util/ui/SnackBarMessageHandler.kt b/app/src/main/kotlin/com/wire/android/util/ui/SnackBarMessageHandler.kt index 525edb9aba8..f523e0f2540 100644 --- a/app/src/main/kotlin/com/wire/android/util/ui/SnackBarMessageHandler.kt +++ b/app/src/main/kotlin/com/wire/android/util/ui/SnackBarMessageHandler.kt @@ -29,13 +29,15 @@ import kotlinx.coroutines.flow.SharedFlow @Composable fun SnackBarMessageHandler( infoMessages: SharedFlow, - onActionClicked: (SnackBarMessage) -> Unit = {} + onEmitted: () -> Unit = {}, + onActionClicked: (SnackBarMessage) -> Unit = {}, ) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current LaunchedEffect(Unit) { infoMessages.collect { + onEmitted() snackbarHostState.showSnackbar( message = it.uiText.asString(context.resources), actionLabel = it.actionLabel?.asString(context.resources), diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index af3f5924d74..b2c39f82ff0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -100,6 +100,7 @@ Audioanruf starten Wire Jemanden erwähnen + Zurück zur Unterhaltungsliste Unterhaltungsdetails öffnen Nach Personen suchen oder eine neue Gruppe erstellen Senden @@ -117,6 +118,7 @@ Nachricht bearbeiten Weitere Optionen Kontakt hinzufügen + Ping Timer für selbstlöschende Nachrichten festlegen Auflegen Anruf annehmen @@ -142,6 +144,7 @@ Teilnehmer Unterhaltungsdetails schließen Gästezugang anpassen + Timer anpassen Unterhaltungsoptionen öffnen Zurück zu Unterhaltungsdetails Rolle bearbeiten @@ -157,6 +160,7 @@ Öffnen Anruf annehmen Teilen + ausklappen bearbeiten auswählen ausgewählt @@ -167,7 +171,9 @@ Unterhaltung öffnen Link öffnen Benachrichtigungseinstellungen öffnen + Zurück zur Ansicht neue Unterhaltung Unterhaltungsoptionen + Zurück zur neuen Gruppenerstellung Ausstehende Genehmigung der Kontaktanfrage Ihr Profil schließen Ihr Profil @@ -563,6 +569,8 @@ Gruppe verlassen Gruppe löschen + Gruppen + 1:1 Unterhaltungen Alles Sie erhalten alle Benachrichtigungen für diese Unterhaltung, einschließlich Audio- und Videoanrufe @@ -888,6 +896,8 @@ Ignorieren Verschaffen Sie sich Gewissheit über die Identität von %s, bevor Sie den Kontakt hinzufügen. Bitte überprüfen Sie die Identität der Person, bevor Sie die Kontaktanfrage annehmen. + Wire kann diese Person nicht finden + Entweder fehlt die Berechtigung für dieses Benutzerkonto oder die Person nutzt Wire nicht. Unterhaltung kann nicht beginnen Sie können die Unterhaltung mit %1$s im Moment nicht beginnen. %1$s muss Wire zuerst öffnen oder sich neu anmelden. Bitte versuchen Sie es später noch einmal. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d1979030d70..2474c1e8920 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -992,6 +992,8 @@ Игнорировать Перед добавлением убедитесь в личности %s. Прежде чем принять запрос на добавление, верифицируйте личность собеседника. + Wire не может найти этого человека + Возможно, у вас нет разрешения на использование этой учетной записи, либо этот человек отсутствует в Wire. Невозможно начать беседу Вы не можете начать беседу с %1$s прямо сейчас. %1$s следует сначала открыть Wire или авторизоваться. Пожалуйста, повторите попытку позже. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 506a4495b54..826347c0d64 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -198,6 +198,7 @@ Search people by name or username Go back to add participants view Your profile + Your profile, one unread hint. open join a call share @@ -636,6 +637,7 @@ Notifications Add to Favorites + Remove from Favorites Move to Folder Move to Archive Unarchive @@ -904,6 +906,11 @@ Archive conversation? This conversation moves into your archive. You still get new messages, files, and calls, but no notifications. You can unarchive the conversation at any time. Archive + + “%s” was added to Favorites + “%s” was removed from Favorites + “%s” could not be added to Favorites + “%s” could not be removed from Favorites MessageComposeInputState transition HorizontalBouncingWritingPen transition @@ -1575,7 +1582,13 @@ In group conversations, the group admin can overwrite this setting. Step %1$d of 4 Back to Wire Back + Go back to team account overview + Go back to team name view Close team migration flow + Close team account overview + Close team name view + Close confirmation view + Close team created view Team Account Transform your personal account into a team account to get more out of your collaboration. diff --git a/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt b/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt index d5561428db9..0d95340a56a 100644 --- a/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt +++ b/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt @@ -44,7 +44,8 @@ object TestConversationItem { userId = UserId("value", "domain"), isArchived = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + isFavorite = false ) val GROUP = ConversationItem.GroupConversation( @@ -56,10 +57,12 @@ object TestConversationItem { ), badgeEventType = BadgeEventType.UnreadMessage(100), selfMemberRole = null, + isFromTheSameTeam = false, teamId = null, isArchived = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + isFavorite = false ) val CONNECTION = ConversationItem.ConnectionConversation( diff --git a/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt b/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt index f5dbab4b6e5..17cac8553cf 100644 --- a/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt +++ b/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt @@ -145,7 +145,7 @@ object TestMessage { val UI_MESSAGE_HEADER = MessageHeader( username = UIText.DynamicString("username"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = MessageTime(Instant.parse("2022-03-30T15:36:00.000Z")), messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, diff --git a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt index 57c51fcb7c9..d761d47ca4c 100644 --- a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt @@ -25,9 +25,12 @@ import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.media.audiomessage.AudioMediaPlayingState import com.wire.android.media.audiomessage.AudioState import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.logic.feature.asset.GetMessageAssetUseCase +import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.asset.MessageAssetResult +import com.wire.kalium.logic.feature.session.CurrentSessionResult import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every @@ -45,11 +48,14 @@ class ConversationAudioMessagePlayerTest { val (arrangement, conversationAudioMessagePlayer) = Arrangement() .withAudioMediaPlayerReturningTotalTime(1000) .withSuccessFullAssetFetch() + .withCurrentSession() .arrange() val testAudioMessageId = "some-dummy-message-id" conversationAudioMessagePlayer.observableAudioMessagesState.test { + // skip first emit from onStart + awaitItem() conversationAudioMessagePlayer.playAudio( ConversationId("some-dummy-value", "some.dummy.domain"), testAudioMessageId @@ -95,6 +101,7 @@ class ConversationAudioMessagePlayerTest { fun givenTheSuccessFullAssetFetch_whenPlayingTheSameMessageIdTwiceSequentially_thenEmitStatesAsExpected() = runTest { val (arrangement, conversationAudioMessagePlayer) = Arrangement() .withSuccessFullAssetFetch() + .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) .withMediaPlayerPlaying() .arrange() @@ -102,6 +109,8 @@ class ConversationAudioMessagePlayerTest { val testAudioMessageId = "some-dummy-message-id" conversationAudioMessagePlayer.observableAudioMessagesState.test { + // skip first emit from onStart + awaitItem() // playing first time conversationAudioMessagePlayer.playAudio( ConversationId("some-dummy-value", "some.dummy.domain"), @@ -161,6 +170,7 @@ class ConversationAudioMessagePlayerTest { runTest { val (arrangement, conversationAudioMessagePlayer) = Arrangement() .withSuccessFullAssetFetch() + .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) .arrange() @@ -168,6 +178,8 @@ class ConversationAudioMessagePlayerTest { val secondAudioMessageId = "some-dummy-message-id2" conversationAudioMessagePlayer.observableAudioMessagesState.test { + // skip first emit from onStart + awaitItem() // playing first audio message conversationAudioMessagePlayer.playAudio( ConversationId("some-dummy-value", "some.dummy.domain"), @@ -242,6 +254,7 @@ class ConversationAudioMessagePlayerTest { runTest { val (arrangement, conversationAudioMessagePlayer) = Arrangement() .withSuccessFullAssetFetch() + .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) .arrange() @@ -249,6 +262,8 @@ class ConversationAudioMessagePlayerTest { val secondAudioMessageId = "some-dummy-message-id2" conversationAudioMessagePlayer.observableAudioMessagesState.test { + // skip first emit from onStart + awaitItem() // playing first audio message conversationAudioMessagePlayer.playAudio( ConversationId("some-dummy-value", "some.dummy.domain"), @@ -366,6 +381,7 @@ class ConversationAudioMessagePlayerTest { runTest { val (arrangement, conversationAudioMessagePlayer) = Arrangement() .withSuccessFullAssetFetch() + .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) .withMediaPlayerPlaying() .arrange() @@ -373,6 +389,8 @@ class ConversationAudioMessagePlayerTest { val testAudioMessageId = "some-dummy-message-id" conversationAudioMessagePlayer.observableAudioMessagesState.test { + // skip first emit from onStart + awaitItem() // playing first time conversationAudioMessagePlayer.playAudio( ConversationId("some-dummy-value", "some.dummy.domain"), @@ -454,7 +472,7 @@ class Arrangement { lateinit var context: Context @MockK - lateinit var getMessageAssetUseCase: GetMessageAssetUseCase + lateinit var coreLogic: CoreLogic @MockK lateinit var mediaPlayer: MediaPlayer @@ -463,7 +481,7 @@ class Arrangement { ConversationAudioMessagePlayer( context, mediaPlayer, - getMessageAssetUseCase, + coreLogic, ) } @@ -471,8 +489,16 @@ class Arrangement { MockKAnnotations.init(this, relaxed = true) } + fun withCurrentSession() = apply { + coEvery { coreLogic.getGlobalScope().session.currentSession.invoke() } returns CurrentSessionResult.Success( + AccountInfo.Valid(UserId("some-user-value", "some.user.domain")) + ) + } + fun withSuccessFullAssetFetch() = apply { - coEvery { getMessageAssetUseCase.invoke(any(), any()) } returns CompletableDeferred( + coEvery { + coreLogic.getSessionScope(any()).messages.getAssetMessage.invoke(any(), any()) + } returns CompletableDeferred( MessageAssetResult.Success( decodedAssetPath = FakeKaliumFileSystem().selfUserAvatarPath(), assetSize = 0, diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt index 79e3abce3cb..92937700fdb 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt @@ -23,9 +23,11 @@ import com.wire.android.config.NavigationTestExtension import com.wire.android.datastore.GlobalDataStore import com.wire.android.ui.calling.model.UICallParticipant import com.wire.android.ui.calling.ongoing.OngoingCallViewModel +import com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallClient +import com.wire.kalium.logic.data.call.CallQuality import com.wire.kalium.logic.data.call.CallStatus import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.conversation.Conversation @@ -170,6 +172,72 @@ class OngoingCallViewModelTest { } } + @Test + fun givenAUserIsSelected_whenRequestedFullScreen_thenSetTheUserAsSelected() = + runTest { + val (_, ongoingCallViewModel) = Arrangement() + .withCall(provideCall().copy(isCameraOn = true)) + .withShouldShowDoubleTapToastReturning(false) + .withSetVideoSendState() + .arrange() + + ongoingCallViewModel.onSelectedParticipant(selectedParticipant3) + + assertEquals(selectedParticipant3, ongoingCallViewModel.selectedParticipant) + } + + @Test + fun givenParticipantsList_WhenRequestingVideoStreamForFullScreenParticipant_ThenRequestItInHighQuality() = + runTest { + val expectedClients = listOf( + CallClient(participant1.id.toString(), participant1.clientId, false, CallQuality.LOW), + CallClient(participant3.id.toString(), participant3.clientId, false, CallQuality.HIGH) + ) + + val (arrangement, ongoingCallViewModel) = Arrangement() + .withCall(provideCall()) + .withShouldShowDoubleTapToastReturning(false) + .withSetVideoSendState() + .withRequestVideoStreams(conversationId, expectedClients) + .arrange() + + ongoingCallViewModel.onSelectedParticipant(selectedParticipant3) + ongoingCallViewModel.requestVideoStreams(participants) + + coVerify(exactly = 1) { + arrangement.requestVideoStreams( + conversationId, + expectedClients + ) + } + } + + @Test + fun givenParticipantsList_WhenRequestingVideoStreamForAllParticipant_ThenRequestItInLowQuality() = + runTest { + val expectedClients = listOf( + CallClient(participant1.id.toString(), participant1.clientId, false, CallQuality.LOW), + CallClient(participant3.id.toString(), participant3.clientId, false, CallQuality.LOW) + ) + + val (arrangement, ongoingCallViewModel) = Arrangement() + .withCall(provideCall()) + .withShouldShowDoubleTapToastReturning(false) + .withSetVideoSendState() + .withRequestVideoStreams(conversationId, expectedClients) + .arrange() + + ongoingCallViewModel.onSelectedParticipant(SelectedParticipant()) + ongoingCallViewModel.requestVideoStreams(participants) + + coVerify(exactly = 1) { + arrangement.requestVideoStreams( + conversationId, + expectedClients + ) + } + } + private class Arrangement { @MockK @@ -268,6 +336,7 @@ class OngoingCallViewModelTest { accentId = -1 ) val participants = listOf(participant1, participant2, participant3) + val selectedParticipant3 = SelectedParticipant(participant3.id, participant3.clientId, false) } private fun provideCall( diff --git a/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt index 09f6b2160ab..9b9d8d5b273 100644 --- a/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt @@ -41,7 +41,8 @@ class ConversationSheetContentTest { protocol = Conversation.ProtocolInfo.Proteus, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isUnderLegalHold = false + isUnderLegalHold = false, + isFavorite = false ) val givenParticipantsCount = 1 @@ -63,7 +64,8 @@ class ConversationSheetContentTest { protocol = Conversation.ProtocolInfo.Proteus, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isUnderLegalHold = false + isUnderLegalHold = false, + isFavorite = false ) val givenParticipantsCount = 3 @@ -85,7 +87,8 @@ class ConversationSheetContentTest { protocol = Conversation.ProtocolInfo.Proteus, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isUnderLegalHold = false + isUnderLegalHold = false, + isFavorite = false ) val givenParticipantsCount = 3 diff --git a/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/folder/ChangeConversationFavoriteVMTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/folder/ChangeConversationFavoriteVMTest.kt new file mode 100644 index 00000000000..330ef97d5c9 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/folder/ChangeConversationFavoriteVMTest.kt @@ -0,0 +1,151 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.bottomsheet.folder + +import app.cash.turbine.test +import com.wire.android.R +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.framework.TestConversation +import com.wire.android.model.asSnackBarMessage +import com.wire.android.ui.home.conversationslist.model.GroupDialogState +import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.feature.conversation.folder.AddConversationToFavoritesUseCase +import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFavoritesUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class) +class ChangeConversationFavoriteVMTest { + + @Test + fun `given conversation is added to favorites successfully, then infoMessage should emit success`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange { + withAddToFavoritesResult(AddConversationToFavoritesUseCase.Result.Success) + } + + viewModel.infoMessage.test { + + viewModel.changeFavoriteState(dialogState, addToFavorite = true) + + assertEquals( + UIText.StringResource(R.string.success_adding_to_favorite, conversationName).asSnackBarMessage(), + awaitItem() + ) + coVerify(exactly = 1) { + arrangement.addConversationToFavorites(dialogState.conversationId) + } + } + } + + @Test + fun `given conversation fails to add to favorites, then infoMessage should emit error`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange { + withAddToFavoritesResult(AddConversationToFavoritesUseCase.Result.Failure(CoreFailure.Unknown(null))) + } + viewModel.infoMessage.test { + viewModel.changeFavoriteState(dialogState, addToFavorite = true) + + assertEquals( + UIText.StringResource(R.string.error_adding_to_favorite, conversationName).asSnackBarMessage(), + awaitItem() + ) + coVerify(exactly = 1) { + arrangement.addConversationToFavorites(dialogState.conversationId) + } + } + } + + @Test + fun `given conversation is removed from favorites successfully, then infoMessage should emit success`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange { + withRemoveFromFavoritesResult(RemoveConversationFromFavoritesUseCase.Result.Success) + } + viewModel.infoMessage.test { + viewModel.changeFavoriteState(dialogState, addToFavorite = false) + + assertEquals( + UIText.StringResource(R.string.success_removing_from_favorite, conversationName).asSnackBarMessage(), + awaitItem() + ) + coVerify(exactly = 1) { + arrangement.removeConversationFromFavorites(dialogState.conversationId) + } + } + } + + @Test + fun `given conversation fails to remove from favorites, then infoMessage should emit error`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange { + withRemoveFromFavoritesResult(RemoveConversationFromFavoritesUseCase.Result.Failure(CoreFailure.Unknown(null))) + } + viewModel.infoMessage.test { + viewModel.changeFavoriteState(dialogState, addToFavorite = false) + + assertEquals( + UIText.StringResource(R.string.error_removing_from_favorite, conversationName).asSnackBarMessage(), + awaitItem() + ) + coVerify(exactly = 1) { + arrangement.removeConversationFromFavorites(dialogState.conversationId) + } + } + } + + companion object { + val dialogState = GroupDialogState(conversationId = TestConversation.ID, conversationName = "Test Conversation") + val conversationName = dialogState.conversationName + } + + private class Arrangement { + + @MockK + lateinit var addConversationToFavorites: AddConversationToFavoritesUseCase + + @MockK + lateinit var removeConversationFromFavorites: RemoveConversationFromFavoritesUseCase + + private lateinit var viewModel: ChangeConversationFavoriteVM + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + } + + fun withAddToFavoritesResult(result: AddConversationToFavoritesUseCase.Result) = apply { + coEvery { addConversationToFavorites(any()) } returns result + } + + fun withRemoveFromFavoritesResult(result: RemoveConversationFromFavoritesUseCase.Result) = apply { + coEvery { removeConversationFromFavorites(any()) } returns result + } + + fun arrange(block: Arrangement.() -> Unit) = apply(block).let { + viewModel = ChangeConversationFavoriteVMImpl( + addConversationToFavorites, + removeConversationFromFavorites + ) + this to viewModel + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandlerTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandlerTest.kt new file mode 100644 index 00000000000..f28ad9b2b00 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandlerTest.kt @@ -0,0 +1,97 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.textfield + +import androidx.compose.ui.text.TextRange +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class MentionDeletionHandlerTest { + + @Test + fun `given mention in text when deleting inside mention then mention is removed`() { + val oldText = "Hello @John Doe, how are you?" + val newText = "Hello , how are you?" + val oldSelection = TextRange(6, 17) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals("Hello , how are you?", result) + } + + @Test + fun `given mention with last character deleted when deleting last character then mention is removed`() { + val oldText = "Hello @John Doe, how are you?" + val newText = "Hello @John Do, how are you?" + val oldSelection = TextRange(3, 13) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals("Hello , how are you?", result) + } + + @Test + fun `given cursor at beginning of mention when no deletion then text remains unchanged`() { + val oldText = "Hello @John Doe, how are you?" + val newText = "Hello @John Doe, how are you?" + val oldSelection = TextRange(6, 6) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals(oldText, result) + } + + @Test + fun `given text with mention when deleting outside of mention then text remains unchanged`() { + val oldText = "Hello @John Doe, how are you?" + val newText = "Hello @John Doehow are you?" + val oldSelection = TextRange(5, 6) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals(newText, result) + } + + @Test + fun `given multiple mentions in text when deleting inside mentions then all mentions are removed`() { + val oldText = "Hello @John Doe and @Jane Doe, how are you?" + val newText = "Hello , how are you?" + val oldSelection = TextRange(6, 17) + val mentions = listOf("@John Doe", "@Jane Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals(newText, result) + } + + @Test + fun `given text without mentions when no mentions to delete then text remains unchanged`() { + val oldText = "Hello there, how are you?" + val newText = "Hello, how are you?" + val oldSelection = TextRange(6, 6) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals(newText, result) + } +} 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 a0413084b0e..4387a91ec30 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 @@ -225,7 +225,7 @@ internal fun mockUITextMessage(id: String = "someId", userName: String = "mockUs every { it.header } returns mockk().also { every { it.messageId } returns id every { it.username } returns UIText.DynamicString(userName) - every { it.isLegalHold } returns false + every { it.showLegalHoldIndicator } returns false every { it.messageTime } returns MessageTime(Instant.DISTANT_PAST) every { it.messageStatus } returns MessageStatus( flowStatus = MessageFlowStatus.Sent, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt index 3ee92bb493e..d9df11671fb 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt @@ -153,7 +153,7 @@ class GroupConversationDetailsViewModelTest { conversationName = conversationDetails.conversation.name.orEmpty(), conversationTypeDetail = ConversationTypeDetail.Group( conversationId = conversationDetails.conversation.id, - isCreator = conversationDetails.isSelfUserCreator + isFromTheSameTeam = false ), isArchived = conversationDetails.conversation.archived, isMember = true @@ -202,7 +202,7 @@ class GroupConversationDetailsViewModelTest { conversationName = conversationDetails.conversation.name.orEmpty(), conversationTypeDetail = ConversationTypeDetail.Group( conversationId = conversationDetails.conversation.id, - isCreator = conversationDetails.isSelfUserCreator + isFromTheSameTeam = false ), isArchived = conversationDetails.conversation.archived, isMember = true @@ -452,6 +452,7 @@ class GroupConversationDetailsViewModelTest { mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isUnderLegalHold = true, + isFavorite = false ) // When - Then assertEquals(expected, viewModel.conversationSheetContent) 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 491c2fd2e56..bc48d1c4fe4 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 @@ -24,6 +24,7 @@ import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.media.audiomessage.AudioState import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer +import com.wire.android.media.audiomessage.ConversationAudioMessagePlayerProvider import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.model.UIMessage @@ -96,6 +97,9 @@ class ConversationMessagesViewModelArrangement { @MockK lateinit var conversationAudioMessagePlayer: ConversationAudioMessagePlayer + @MockK + lateinit var conversationAudioMessagePlayerProvider: ConversationAudioMessagePlayerProvider + @MockK lateinit var getConversationUnreadEventsCount: GetConversationUnreadEventsCountUseCase @@ -124,7 +128,7 @@ class ConversationMessagesViewModelArrangement { getMessagesForConversationUseCase, toggleReaction, resetSession, - conversationAudioMessagePlayer, + conversationAudioMessagePlayerProvider, getConversationUnreadEventsCount, clearUsersTypingEvents, getSearchedConversationMessagePosition, @@ -143,6 +147,8 @@ class ConversationMessagesViewModelArrangement { coEvery { getConversationUnreadEventsCount(any()) } returns GetConversationUnreadEventsCountUseCase.Result.Success(0L) coEvery { updateAssetMessageDownloadStatus(any(), any(), any()) } returns UpdateTransferStatusResult.Success coEvery { clearUsersTypingEvents() } returns Unit + every { conversationAudioMessagePlayerProvider.provide() } returns conversationAudioMessagePlayer + every { conversationAudioMessagePlayerProvider.onCleared() } returns Unit coEvery { getSearchedConversationMessagePosition(any(), any()) } returns GetSearchedConversationMessagePositionUseCase.Result.Success(position = 0) diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt index e18ded05dfe..16dc6cf6c49 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt @@ -22,7 +22,9 @@ import androidx.paging.testing.asSnapshot import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.framework.TestConversationDetails +import com.wire.android.framework.TestUser import com.wire.android.mapper.UserTypeMapper +import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents @@ -33,6 +35,7 @@ import com.wire.kalium.logic.data.conversation.FolderType import com.wire.kalium.logic.feature.conversation.GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase import com.wire.kalium.logic.feature.conversation.folder.GetFavoriteFolderUseCase import com.wire.kalium.logic.feature.conversation.folder.ObserveConversationsFromFolderUseCase +import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -42,6 +45,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertInstanceOf import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @@ -73,6 +77,7 @@ class GetConversationsFromSearchUseCaseTest { ) val (arrangement, useCase) = Arrangement() .withPaginatedResult(conversationsList) + .withSelfUser() .arrange() // When val result = with(arrangement.queryConfig) { @@ -99,6 +104,7 @@ class GetConversationsFromSearchUseCaseTest { val (arrangement, useCase) = Arrangement() .withFavoriteFolderResult(folderResult) .withFolderConversationsResult(conversationsList) + .withSelfUser() .arrange() // When @@ -116,6 +122,52 @@ class GetConversationsFromSearchUseCaseTest { coVerify(exactly = 0) { arrangement.useCase(any(), any(), any()) } } + @Test + fun givenGroupConversation_whenConversationFromTheSameTeam_thenReturnDataWithProperlySameTeamSet() = + runTest(dispatcherProvider.main()) { + // Given + val conversationsList = listOf( + ConversationDetailsWithEvents( + TestConversationDetails.GROUP.copy( + conversation = TestConversationDetails.GROUP.conversation.copy( + teamId = TestUser.SELF_USER.teamId + ) + ) + ) + ) + val (arrangement, useCase) = Arrangement() + .withPaginatedResult(conversationsList) + .withSelfUser() + .arrange() + // When + val result = with(arrangement.queryConfig) { + useCase(searchQuery, fromArchive, newActivitiesOnTop, onlyInteractionEnabled).asSnapshot() + } + // Then + val conversation = result.first() + assertInstanceOf(conversation) + assertEquals(true, conversation.isFromTheSameTeam) + } + + @Test + fun givenGroupConversation_whenConversationNotFromTheSameTeam_thenReturnDataWithProperlySameTeamSet() = + runTest(dispatcherProvider.main()) { + // Given + val conversationsList = listOf(ConversationDetailsWithEvents(TestConversationDetails.GROUP)) + val (arrangement, useCase) = Arrangement() + .withPaginatedResult(conversationsList) + .withSelfUser() + .arrange() + // When + val result = with(arrangement.queryConfig) { + useCase(searchQuery, fromArchive, newActivitiesOnTop, onlyInteractionEnabled).asSnapshot() + } + // Then + val conversation = result.first() + assertInstanceOf(conversation) + assertEquals(false, conversation.isFromTheSameTeam) + } + inner class Arrangement { @MockK @@ -133,6 +185,9 @@ class GetConversationsFromSearchUseCaseTest { @MockK lateinit var userTypeMapper: UserTypeMapper + @MockK + lateinit var observeSelfUser: GetSelfUserUseCase + val queryConfig = ConversationQueryConfig( searchQuery = "search", fromArchive = false, @@ -162,13 +217,18 @@ class GetConversationsFromSearchUseCaseTest { } returns flowOf(conversations) } + fun withSelfUser() = apply { + coEvery { observeSelfUser() } returns flowOf(TestUser.SELF_USER) + } + fun arrange() = this to GetConversationsFromSearchUseCase( useCase, getFavoriteFolderUseCase, observeConversationsFromFolderUseCase, wireSessionImageLoader, userTypeMapper, - dispatcherProvider + dispatcherProvider, + observeSelfUser ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt index d9e1cdb253f..265393c65f8 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt @@ -19,7 +19,11 @@ package com.wire.android.ui.home.conversationslist +import androidx.paging.LoadState +import androidx.paging.LoadStates import androidx.paging.PagingData +import androidx.paging.testing.asSnapshot +import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri @@ -29,6 +33,7 @@ import com.wire.android.framework.TestUser import com.wire.android.mapper.UserTypeMapper import com.wire.android.ui.common.dialogs.BlockUserDialogState import com.wire.android.ui.home.conversations.usecase.GetConversationsFromSearchUseCase +import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationsSource import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents @@ -47,15 +52,20 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationListDetails import com.wire.kalium.logic.feature.conversation.RefreshConversationsWithoutMetadataUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationArchivedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationMutedStatusUseCase +import com.wire.kalium.logic.feature.legalhold.LegalHoldStateForSelfUser +import com.wire.kalium.logic.feature.legalhold.ObserveLegalHoldStateForSelfUserUseCase import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase import com.wire.kalium.logic.feature.team.DeleteTeamConversationUseCase +import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -66,64 +76,116 @@ class ConversationListViewModelTest { private val dispatcherProvider = TestDispatcherProvider() - // TODO: reenable this test once pagination is implemented -// @Test -// fun `given initial empty search query, when collecting conversations, then call use case with proper params`() = -// runTest(dispatcherProvider.main()) { -// // Given -// val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() -// -// // When -// conversationListViewModel.conversationListState.foldersWithConversations.test { -// // Then -// coVerify(exactly = 1) { -// arrangement.getConversationsPaginated("", false, true, false) -// } -// cancelAndIgnoreRemainingEvents() -// } -// } - - // TODO: reenable this test once pagination is implemented -// @Test -// fun `given updated non-empty search query, when collecting conversations, then call use case with proper params`() = -// runTest(dispatcherProvider.main()) { -// // Given -// val searchQueryText = "search" -// val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() -// -// // When -// conversationListViewModel.conversationListState.foldersWithConversations.test { -// conversationListViewModel.searchQueryChanged(searchQueryText) -// advanceUntilIdle() -// -// // Then -// coVerify(exactly = 1) { -// arrangement.getConversationsPaginated(searchQueryText, false, true, false) -// } -// cancelAndIgnoreRemainingEvents() -// } -// } - - // TODO: reenable this test once pagination is implemented -// @Test -// fun `given updated non-empty search query, when collecting archived, then call use case with proper params`() = -// runTest(dispatcherProvider.main()) { -// // Given -// val searchQueryText = "search" -// val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.ARCHIVE).arrange() -// -// // When -// conversationListViewModel.conversationListState.foldersWithConversations.test { -// conversationListViewModel.searchQueryChanged(searchQueryText) -// advanceUntilIdle() -// -// // Then -// coVerify(exactly = 1) { -// arrangement.getConversationsPaginated(searchQueryText, true, false, false) -// } -// cancelAndIgnoreRemainingEvents() -// } -// } + @Test + fun `given initial empty search query, when collecting conversations, then call use case with proper params`() = + runTest(dispatcherProvider.main()) { + // Given + val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { + // Then + coVerify(exactly = 1) { + arrangement.getConversationsPaginated("", false, true, false) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `given updated non-empty search query, when collecting conversations, then call use case with proper params`() = + runTest(dispatcherProvider.main()) { + // Given + val searchQueryText = "search" + val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { + conversationListViewModel.searchQueryChanged(searchQueryText) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + arrangement.getConversationsPaginated(searchQueryText, false, true, false) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `given updated non-empty search query, when collecting archived, then call use case with proper params`() = + runTest(dispatcherProvider.main()) { + // Given + val searchQueryText = "search" + val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.ARCHIVE).arrange() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { + conversationListViewModel.searchQueryChanged(searchQueryText) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + arrangement.getConversationsPaginated(searchQueryText, true, false, false) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `given self user is under legal hold, when collecting conversations, then hide LH indicators`() = + runTest(dispatcherProvider.main()) { + // Given + val conversations = listOf( + TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_1", ""), showLegalHoldIndicator = true), + TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_2", ""), showLegalHoldIndicator = false), + TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_1", ""), showLegalHoldIndicator = true), + TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_2", ""), showLegalHoldIndicator = false), + TestConversationItem.GROUP.copy(conversationId = ConversationId("group_1", ""), showLegalHoldIndicator = true), + TestConversationItem.GROUP.copy(conversationId = ConversationId("group_2", ""), showLegalHoldIndicator = false), + ).associateBy { it.conversationId } + val (_, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN) + .withConversationsPaginated(conversations.values.toList()) + .withSelfUserLegalHoldState(LegalHoldStateForSelfUser.Enabled) + .arrange() + advanceUntilIdle() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.asSnapshot() + .filterIsInstance() + .forEach { + // Then + assertEquals(false, it.showLegalHoldIndicator) // self user is under LH so hide LH indicators next to conversations + } + } + + @Test + fun `given self user is not under legal hold, when collecting conversations, then show LH indicator when conversation is under LH`() = + runTest(dispatcherProvider.main()) { + // Given + val conversations = listOf( + TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_1", ""), showLegalHoldIndicator = true), + TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_2", ""), showLegalHoldIndicator = false), + TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_1", ""), showLegalHoldIndicator = true), + TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_2", ""), showLegalHoldIndicator = false), + TestConversationItem.GROUP.copy(conversationId = ConversationId("group_1", ""), showLegalHoldIndicator = true), + TestConversationItem.GROUP.copy(conversationId = ConversationId("group_2", ""), showLegalHoldIndicator = false), + ).associateBy { it.conversationId } + val (_, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN) + .withConversationsPaginated(conversations.values.toList()) + .withSelfUserLegalHoldState(LegalHoldStateForSelfUser.Disabled) + .arrange() + advanceUntilIdle() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.asSnapshot() + .filterIsInstance() + .forEach { + // Then + val expected = conversations[it.conversationId]!!.showLegalHoldIndicator // show indicator when conversation is under LH + assertEquals(expected, it.showLegalHoldIndicator) + } + } @Test fun `given a valid conversation muting state, when calling muteConversation, then should call with call the UseCase`() = @@ -207,16 +269,19 @@ class ConversationListViewModelTest { private lateinit var observeConversationListDetailsWithEventsUseCase: ObserveConversationListDetailsWithEventsUseCase + @MockK + private lateinit var observeLegalHoldStateForSelfUserUseCase: ObserveLegalHoldStateForSelfUserUseCase + @MockK private lateinit var wireSessionImageLoader: WireSessionImageLoader + @MockK + private lateinit var observeSelfUser: GetSelfUserUseCase + init { MockKAnnotations.init(this, relaxUnitFun = true) - coEvery { - getConversationsPaginated.invoke(any(), any(), any(), any()) - } returns flowOf( - PagingData.from(listOf(TestConversationItem.CONNECTION, TestConversationItem.PRIVATE, TestConversationItem.GROUP)) - ) + withConversationsPaginated(listOf(TestConversationItem.CONNECTION, TestConversationItem.PRIVATE, TestConversationItem.GROUP)) + withSelfUserLegalHoldState(LegalHoldStateForSelfUser.Disabled) coEvery { observeConversationListDetailsWithEventsUseCase.invoke(false, ConversationFilter.ALL) } returns flowOf( listOf( TestConversationDetails.CONNECTION, @@ -245,6 +310,25 @@ class ConversationListViewModelTest { coEvery { unblockUser(any()) } returns UnblockUserResult.Success } + fun withConversationsPaginated(items: List) = apply { + coEvery { + getConversationsPaginated.invoke(any(), any(), any(), any()) + } returns flowOf( + PagingData.from( + data = items, + sourceLoadStates = LoadStates( + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true), + refresh = LoadState.NotLoading(true), + ), + ) + ) + } + + fun withSelfUserLegalHoldState(LegalHoldStateForSelfUser: LegalHoldStateForSelfUser) = apply { + coEvery { observeLegalHoldStateForSelfUserUseCase() } returns flowOf(LegalHoldStateForSelfUser) + } + fun arrange() = this to ConversationListViewModelImpl( conversationsSource = conversationsSource, dispatcher = dispatcherProvider, @@ -260,8 +344,11 @@ class ConversationListViewModelTest { updateConversationArchivedStatus = updateConversationArchivedStatus, currentAccount = TestUser.SELF_USER_ID, observeConversationListDetailsWithEvents = observeConversationListDetailsWithEventsUseCase, + observeLegalHoldStateForSelfUser = observeLegalHoldStateForSelfUserUseCase, userTypeMapper = UserTypeMapper(), - wireSessionImageLoader = wireSessionImageLoader + wireSessionImageLoader = wireSessionImageLoader, + observeSelfUser = observeSelfUser, + usePagination = true ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt index 03fe454d77a..8a55dfb5bd5 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt @@ -19,11 +19,12 @@ package com.wire.android.ui.home.messagecomposer import android.content.Context -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.framework.TestConversation @@ -61,10 +62,10 @@ class MessageComposerStateHolderTest { private lateinit var messageComposerViewState: MutableState private lateinit var messageComposition: MutableState private lateinit var messageCompositionInputStateHolder: MessageCompositionInputStateHolder - private lateinit var messageCompositionHolder: MessageCompositionHolder + private lateinit var messageCompositionHolder: State private lateinit var additionalOptionStateHolder: AdditionalOptionStateHolder private lateinit var state: MessageComposerStateHolder - private lateinit var messageTextState: TextFieldState + private lateinit var messageTextFieldValue: MutableState @BeforeEach fun before() { @@ -73,20 +74,22 @@ class MessageComposerStateHolderTest { every { focusRequester.captureFocus() } returns true messageComposerViewState = mutableStateOf(MessageComposerViewState()) messageComposition = mutableStateOf(MessageComposition(TestConversation.ID)) - messageTextState = TextFieldState() + messageTextFieldValue = mutableStateOf(TextFieldValue()) messageCompositionInputStateHolder = MessageCompositionInputStateHolder( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = null, focusRequester = focusRequester ) - messageCompositionHolder = MessageCompositionHolder( - messageComposition = messageComposition, - messageTextState = messageTextState, - onClearDraft = {}, - onSaveDraft = {}, - onSearchMentionQueryChanged = {}, - onClearMentionSearchResult = {}, - onTypingEvent = {}, + messageCompositionHolder = mutableStateOf( + MessageCompositionHolder( + messageComposition = messageComposition, + messageTextFieldValue = messageTextFieldValue, + onClearDraft = {}, + onSaveDraft = {}, + onSearchMentionQueryChanged = {}, + onClearMentionSearchResult = {}, + onTypingEvent = {}, + ) ) additionalOptionStateHolder = AdditionalOptionStateHolder() @@ -131,9 +134,13 @@ class MessageComposerStateHolderTest { editMessageText = "edit_message_text", mentions = listOf() ) - state.messageCompositionHolder.messageTextState.edit { - append("some text") - } + + state.messageCompositionHolder.value.messageTextFieldValue.value = + messageTextFieldValue.value.copy( + text = messageTextFieldValue.value.text + "some text", + selection = TextRange(messageTextFieldValue.value.text.length + "some text".length) + ) + assertInstanceOf(InputType.Editing::class.java, messageCompositionInputStateHolder.inputType).also { assertEquals(true, it.isEditButtonEnabled) } @@ -147,7 +154,7 @@ class MessageComposerStateHolderTest { state.toReply(mockMessageWithText) // then - assertEquals(String.EMPTY, messageCompositionHolder.messageTextState.text.toString()) + assertEquals(String.EMPTY, messageCompositionHolder.value.messageTextFieldValue.value.text) assertInstanceOf(InputType.Composing::class.java, messageCompositionInputStateHolder.inputType) } @@ -155,13 +162,15 @@ class MessageComposerStateHolderTest { fun `given some message was being composed, when setting toReply, then input continues with the current text`() = runTest { // given val currentText = "Potato" - messageCompositionHolder.messageTextState.setTextAndPlaceCursorAtEnd(currentText) - + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = currentText, + selection = TextRange(currentText.length) + ) // when state.toReply(mockMessageWithText) // then - assertEquals(currentText, messageCompositionHolder.messageTextState.text.toString()) + assertEquals(currentText, messageCompositionHolder.value.messageTextFieldValue.value.text) } @Test @@ -188,15 +197,15 @@ class MessageComposerStateHolderTest { // then assertEquals( String.EMPTY, - messageCompositionHolder.messageTextState.text.toString() + messageCompositionHolder.value.messageTextFieldValue.value.text ) assertEquals( null, - messageCompositionHolder.messageComposition.value.quotedMessage + messageCompositionHolder.value.messageComposition.value.quotedMessage ) assertEquals( null, - messageCompositionHolder.messageComposition.value.quotedMessageId + messageCompositionHolder.value.messageComposition.value.quotedMessageId ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt index 91fee977b50..e0cab8fb694 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt @@ -18,10 +18,10 @@ package com.wire.android.ui.home.messagecomposer.state import android.content.Context -import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.config.SnapshotExtension import com.wire.android.framework.TestConversation import com.wire.android.ui.home.messagecomposer.model.MessageComposition @@ -47,7 +47,7 @@ class MessageCompositionHolderTest { private lateinit var state: MessageCompositionHolder private lateinit var messageComposition: MutableState - private lateinit var messageTextState: TextFieldState + private lateinit var messageTextFieldValue: MutableState private val dispatcher = StandardTestDispatcher() @BeforeEach @@ -56,10 +56,10 @@ class MessageCompositionHolderTest { Dispatchers.setMain(dispatcher) messageComposition = mutableStateOf(MessageComposition(TestConversation.ID)) - messageTextState = TextFieldState() + messageTextFieldValue = mutableStateOf(TextFieldValue()) state = MessageCompositionHolder( messageComposition = messageComposition, - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, onClearDraft = {}, onSaveDraft = {}, onSearchMentionQueryChanged = {}, @@ -82,7 +82,7 @@ class MessageCompositionHolderTest { // then assertEquals( "# ", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @@ -95,7 +95,7 @@ class MessageCompositionHolderTest { // then assertEquals( "****", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @@ -108,62 +108,52 @@ class MessageCompositionHolderTest { // then assertEquals( "__", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @Test fun `given non empty text, when adding header markdown on selection, then # is added to the text`() = runTest { // given - state.messageTextState.edit { - replace(0, length, "header") - selection = TextRange( - start = 0, - end = 6 - ) - } - + val newText = "header" + state.messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = newText, + selection = TextRange(0, 6) + ) // when state.addOrRemoveMessageMarkdown(markdown = RichTextMarkdown.Header) // then assertEquals( "# header", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @Test fun `given non empty text, when adding bold markdown on selection, then 2x star char is added to the text`() = runTest { // given - state.messageTextState.edit { - replace(0, length, "bold") - selection = TextRange( - start = 0, - end = 4 - ) - } - + state.messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = "bold", // Replace the entire text with "bold" + selection = TextRange(0, 4) + ) // when state.addOrRemoveMessageMarkdown(markdown = RichTextMarkdown.Bold) // then assertEquals( "**bold**", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @Test fun `given non empty text, when adding italic markdown on selection, then 2x _ is added to the text`() = runTest { // given - state.messageTextState.edit { - replace(0, length, "italic") - selection = TextRange( - start = 0, - end = 6 - ) - } + state.messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = "italic", // Replace the entire text with "bold" + selection = TextRange(0, 6) + ) // when state.addOrRemoveMessageMarkdown(markdown = RichTextMarkdown.Italic) @@ -171,7 +161,7 @@ class MessageCompositionHolderTest { // then assertEquals( "_italic_", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt index 9364fc0fd32..81f3a08a537 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt @@ -20,10 +20,11 @@ package com.wire.android.ui.home.messagecomposer.state -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.SnapshotExtension @@ -233,14 +234,14 @@ class MessageCompositionInputStateHolderTest { class Arrangement { - private val textFieldState = TextFieldState() + private val textFieldValue = mutableStateOf(TextFieldValue()) val softwareKeyboardController = mockk() private val focusRequester = mockk() private val state by lazy { - MessageCompositionInputStateHolder(textFieldState, softwareKeyboardController, focusRequester) + MessageCompositionInputStateHolder(textFieldValue, softwareKeyboardController, focusRequester) } init { @@ -251,7 +252,10 @@ class MessageCompositionInputStateHolderTest { } fun withText(text: String) = apply { - textFieldState.setTextAndPlaceCursorAtEnd(text) + textFieldValue.value = textFieldValue.value.copy( + text = text, + selection = TextRange(text.length) + ) } fun arrange() = state to this diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModelTest.kt index 331f4b5c004..4def3b04039 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModelTest.kt @@ -17,15 +17,26 @@ */ package com.wire.android.ui.userprofile.teammigration +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.wire.android.config.CoroutineTestExtension import com.wire.android.feature.analytics.AnonymousAnalyticsManager import com.wire.android.feature.analytics.model.AnalyticsEvent +import com.wire.kalium.logic.NetworkFailure +import com.wire.kalium.logic.feature.user.migration.MigrateFromPersonalToTeamResult +import com.wire.kalium.logic.feature.user.migration.MigrateFromPersonalToTeamUseCase import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.impl.annotations.MockK +import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.test.runTest import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +@ExtendWith(CoroutineTestExtension::class) class TeamMigrationViewModelTest { @Test @@ -157,17 +168,75 @@ class TeamMigrationViewModelTest { } } + @Test + fun `given team name, when migrateFromPersonalToTeamAccount return success, then call use case and onSuccess`() = + runTest { + val (arrangement, viewModel) = Arrangement() + .withMigrateFromPersonalToTeamSuccess() + .arrange() + + val onSuccess = mockk<() -> Unit>(relaxed = true) + + viewModel.migrateFromPersonalToTeamAccount(onSuccess) + + coVerify(exactly = 1) { + arrangement.migrateFromPersonalToTeam(Arrangement.TEAM_NAME) + } + verify(exactly = 1) { onSuccess() } + } + + @Test + fun `given team name, when migrateFromPersonalToTeamAccount return failure, then call use case and handle the failure`() = + runTest { + val (arrangement, viewModel) = Arrangement() + .withMigrateFromPersonalToTeamError() + .arrange() + + val onSuccess = {} + + viewModel.migrateFromPersonalToTeamAccount(onSuccess) + + coVerify(exactly = 1) { + arrangement.migrateFromPersonalToTeam(Arrangement.TEAM_NAME) + } + Assertions.assertNotNull(viewModel.teamMigrationState.migrationFailure) + viewModel.failureHandled() + Assertions.assertNull(viewModel.teamMigrationState.migrationFailure) + } + private class Arrangement { @MockK lateinit var anonymousAnalyticsManager: AnonymousAnalyticsManager + @MockK + lateinit var migrateFromPersonalToTeam: MigrateFromPersonalToTeamUseCase + init { MockKAnnotations.init(this, relaxUnitFun = true) } fun arrange() = this to TeamMigrationViewModel( - anonymousAnalyticsManager = anonymousAnalyticsManager - ) + anonymousAnalyticsManager = anonymousAnalyticsManager, + migrateFromPersonalToTeam = migrateFromPersonalToTeam, + ).also { viewModel -> + viewModel.teamMigrationState.teamNameTextState.setTextAndPlaceCursorAtEnd(TEAM_NAME) + } + + fun withMigrateFromPersonalToTeamSuccess() = apply { + coEvery { migrateFromPersonalToTeam(any()) } returns MigrateFromPersonalToTeamResult.Success( + TEAM_NAME + ) + } + + fun withMigrateFromPersonalToTeamError() = apply { + coEvery { migrateFromPersonalToTeam(any()) } returns MigrateFromPersonalToTeamResult.Error( + NetworkFailure.NoNetworkConnection(null) + ) + } + + companion object { + const val TEAM_NAME = "teamName" + } } } diff --git a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt index 78ef8edb4e4..bee5ab38855 100644 --- a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt +++ b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt @@ -25,6 +25,7 @@ import com.wire.android.feature.analytics.model.AnalyticsEventConstants import com.wire.android.feature.analytics.model.AnalyticsSettings import ly.count.android.sdk.Countly import ly.count.android.sdk.CountlyConfig +import ly.count.android.sdk.UtilsInternalLimits class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { @@ -40,12 +41,18 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { context, analyticsSettings.countlyAppKey, analyticsSettings.countlyServerUrl - ) - .enableTemporaryDeviceIdMode() // Nothing is sent until a proper ID is placed - .setLoggingEnabled(analyticsSettings.enableDebugLogging) - countlyConfig.apm.enableAppStartTimeTracking() - countlyConfig.apm.enableForegroundBackgroundTracking() - countlyConfig.setApplication(context.applicationContext as Application) + ).apply { + setApplication(context.applicationContext as Application) + enableTemporaryDeviceIdMode() // Nothing is sent until a proper ID is placed + setLoggingEnabled(analyticsSettings.enableDebugLogging) + crashes.apply { + enableCrashReporting() + } + apm.apply { + enableAppStartTimeTracking() + enableForegroundBackgroundTracking() + } + } Countly.sharedInstance().init(countlyConfig) Countly.sharedInstance().consent().giveConsent(arrayOf("apm")) @@ -67,8 +74,13 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { Countly.sharedInstance().onStop() } + /** + * We need to change our segmentation map to [MutableMap] because + * Countly is doing additional operations on it. + * See [UtilsInternalLimits.removeUnsupportedDataTypes] + */ override fun sendEvent(event: AnalyticsEvent) { - Countly.sharedInstance().events().recordEvent(event.key, event.toSegmentation()) + Countly.sharedInstance().events().recordEvent(event.key, event.toSegmentation().toMutableMap()) } override fun halt() { diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/TextWithLinkSuffix.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/TextWithLinkSuffix.kt index 114c64e2d97..b41ce8649db 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/TextWithLinkSuffix.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/TextWithLinkSuffix.kt @@ -58,7 +58,7 @@ fun TextWithLinkSuffix( textStyle: TextStyle = MaterialTheme.wireTypography.body01, textColor: Color = MaterialTheme.wireColorScheme.onBackground, linkStyle: TextStyle = MaterialTheme.wireTypography.body02, - linkColor: Color = MaterialTheme.wireColorScheme.primary, + linkColor: Color = MaterialTheme.wireColorScheme.onBackground, linkDecoration: TextDecoration = TextDecoration.Underline, onTextLayout: (TextLayoutResult) -> Unit = {} ) { diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt index 5a108fc6a53..324e9477336 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt @@ -115,7 +115,6 @@ data class WireDimensions( val dialogCardMargin: Dp, // UserProfile val userProfileOtherAccItemHeight: Dp, - val createTeamInfoCardButtonWidth: Dp, val createTeamInfoCardButtonHeight: Dp, // Profile Image val imagePreviewHeight: Dp, @@ -222,7 +221,6 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( avatarStatusSize = 14.dp, unReadIndicatorSize = 16.dp, avatarStatusBorderWidth = 2.dp, - createTeamInfoCardButtonWidth = 120.dp, createTeamInfoCardButtonHeight = 32.dp, avatarTemporaryUserBorderWidth = 2.dp, avatarBigTemporaryUserBorderWidth = 4.dp, diff --git a/default.json b/default.json index a1a9eb220cb..bdd844d43ac 100644 --- a/default.json +++ b/default.json @@ -78,7 +78,8 @@ "analytics_enabled": true, "picture_in_picture_enabled": true, "analytics_app_key": "8ffae535f1836ed5f58fd5c8a11c00eca07c5438", - "analytics_server_url": "https://countly.wire.com/" + "analytics_server_url": "https://countly.wire.com/", + "paginated_conversation_list_enabled": true }, "fdroid": { "application_id": "com.wire", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f18f8253b2f..6f99643033a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ detekt = "1.23.6" google-gms = "4.4.2" gms-location = "21.3.0" android-gradlePlugin = "8.5.2" -desugaring = "2.1.2" +desugaring = "2.1.3" firebaseBOM = "33.4.0" fragment = "1.5.6" resaca = "3.0.0"