diff --git a/.github/workflows/build-unified.yml b/.github/workflows/build-unified.yml index ca199b65637..0ee42dee49d 100644 --- a/.github/workflows/build-unified.yml +++ b/.github/workflows/build-unified.yml @@ -239,7 +239,7 @@ jobs: run: mv app/version.txt app/build/outputs/ - name: Upload build artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: ${{ matrix.flavor }}-${{ matrix.variant }}-artifacts path: app/build/outputs/ diff --git a/.github/workflows/generate-screenshots.yml b/.github/workflows/generate-screenshots.yml index 5dd62a1ce87..c65a1c4b5f9 100644 --- a/.github/workflows/generate-screenshots.yml +++ b/.github/workflows/generate-screenshots.yml @@ -63,7 +63,7 @@ jobs: - name: Upload Screenshot Test Report id: upload_artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: screenshot-test-report path: screenshot-test-report.zip diff --git a/.github/workflows/gradle-run-ui-tests.yml b/.github/workflows/gradle-run-ui-tests.yml index 50f490406f9..f605b838ce8 100644 --- a/.github/workflows/gradle-run-ui-tests.yml +++ b/.github/workflows/gradle-run-ui-tests.yml @@ -80,7 +80,7 @@ jobs: zip -r integration-tests-android_${{ env.COMMIT_HASH }}.zip acceptanceTests/flavors/ - name: Upload zipped test results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: integration-tests-android_${{ env.COMMIT_HASH }}-${{ inputs.artifact-suffix }} path: integration-tests-android_${{ env.COMMIT_HASH }}.zip diff --git a/.github/workflows/gradle-run-unit-tests.yml b/.github/workflows/gradle-run-unit-tests.yml index 5af927b31c4..1f53c3af2c1 100644 --- a/.github/workflows/gradle-run-unit-tests.yml +++ b/.github/workflows/gradle-run-unit-tests.yml @@ -56,14 +56,14 @@ jobs: zip -r unit-tests-android_${{ env.COMMIT_HASH }}.zip **/build/test-results/**/*.xml **/build/outputs/androidTest-results/**/*.xml - name: Upload zipped test results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: unit-tests-android_${{ env.COMMIT_HASH }}-${{ inputs.artifact-suffix }} path: unit-tests-android_${{ env.COMMIT_HASH }}.zip # Uploads test results as GitHub artifacts, so publish-test-results can find them later. - name: Upload Test Results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: always() with: name: test-results-${{ inputs.artifact-suffix }} @@ -72,7 +72,7 @@ jobs: **/build/outputs/androidTest-results/**/*.xml - name: Generate report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: report-${{ inputs.artifact-suffix }} path: app/build/reports/kover diff --git a/app/src/androidTest/kotlin/com/wire/android/TestCoreLogicModule.kt b/app/src/androidTest/kotlin/com/wire/android/TestCoreLogicModule.kt index 2049f321c3f..016c7d3c740 100644 --- a/app/src/androidTest/kotlin/com/wire/android/TestCoreLogicModule.kt +++ b/app/src/androidTest/kotlin/com/wire/android/TestCoreLogicModule.kt @@ -27,6 +27,7 @@ import com.wire.android.util.UserAgentProvider import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.id.QualifiedIdMapperImpl +import com.wire.kalium.logic.feature.asset.AudioNormalizedLoudnessBuilder import com.wire.kalium.logic.feature.server.ServerConfigForAccountUseCase import com.wire.kalium.logic.feature.session.GetSessionsUseCase import com.wire.kalium.logic.feature.session.ObserveSessionsUseCase @@ -115,4 +116,8 @@ class TestCoreLogicModule { @Singleton @Provides fun provideWorkManager(@ApplicationContext applicationContext: Context) = WorkManager.getInstance(applicationContext) + + @Provides + fun provideAudioNormalizedLoudnessBuilder(@KaliumCoreLogic coreLogic: CoreLogic): AudioNormalizedLoudnessBuilder = + coreLogic.audioNormalizedLoudnessBuilder } 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 cb8153c0fee..32ee1708a74 100644 --- a/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt @@ -30,6 +30,7 @@ import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.id.QualifiedIdMapperImpl import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.analytics.GetCurrentAnalyticsTrackingIdentifierUseCase +import com.wire.kalium.logic.feature.asset.AudioNormalizedLoudnessBuilder import com.wire.kalium.logic.feature.auth.AddAuthenticatedUserUseCase import com.wire.kalium.logic.feature.auth.LogoutUseCase import com.wire.kalium.logic.feature.auth.sso.ValidateSSOCodeUseCase @@ -143,6 +144,10 @@ class CoreLogicModule { @Singleton @Provides fun provideWorkManager(@ApplicationContext applicationContext: Context) = WorkManager.getInstance(applicationContext) + + @Provides + fun provideAudioNormalizedLoudnessBuilder(@KaliumCoreLogic coreLogic: CoreLogic): AudioNormalizedLoudnessBuilder = + coreLogic.audioNormalizedLoudnessBuilder } @Module diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt index 00a1490711c..21883611683 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt @@ -27,7 +27,9 @@ import com.wire.kalium.cells.domain.usecase.CreateFolderUseCase import com.wire.kalium.cells.domain.usecase.DeleteCellAssetUseCase import com.wire.kalium.cells.domain.usecase.DownloadCellFileUseCase import com.wire.kalium.cells.domain.usecase.GetAllTagsUseCase +import com.wire.kalium.cells.domain.usecase.GetCellFileUseCase import com.wire.kalium.cells.domain.usecase.GetFoldersUseCase +import com.wire.kalium.cells.domain.usecase.GetMessageAttachmentUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedNodesUseCase import com.wire.kalium.cells.domain.usecase.IsAtLeastOneCellAvailableUseCase @@ -164,6 +166,13 @@ class CellsModule { @Provides fun provideCellAvailableUseCase(cellsScope: CellsScope): IsAtLeastOneCellAvailableUseCase = cellsScope.isCellAvailable + @ViewModelScoped + @Provides + fun provideGetAttachmentUseCase(cellsScope: CellsScope): GetMessageAttachmentUseCase = cellsScope.getMessageAttachmentUseCase + @Provides fun provideFileNameResolver(): FileNameResolver = FileNameResolver() + + @Provides + fun provideGetCellNodeUseCase(cellsScope: CellsScope): GetCellFileUseCase = cellsScope.getCellFileUseCase } 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 3adecbb4c70..f724cd8d855 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 @@ -59,6 +59,7 @@ import com.wire.kalium.logic.feature.conversation.UpdateConversationMemberRoleUs import com.wire.kalium.logic.feature.conversation.UpdateConversationMutedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationReadDateUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationReceiptModeUseCase +import com.wire.kalium.logic.feature.conversation.apps.ChangeAccessForAppsInConversationUseCase import com.wire.kalium.logic.feature.conversation.createconversation.CreateChannelUseCase import com.wire.kalium.logic.feature.conversation.createconversation.CreateRegularGroupUseCase import com.wire.kalium.logic.feature.conversation.delete.MarkConversationAsDeletedLocallyUseCase @@ -385,4 +386,11 @@ class ConversationModule { @KaliumCoreLogic coreLogic: CoreLogic, @CurrentAccount currentAccount: UserId ): FetchConversationUseCase = coreLogic.getSessionScope(currentAccount).fetchConversationUseCase + + @ViewModelScoped + @Provides + fun provideChangeAccessForAppsInConversationUseCase( + conversationScope: ConversationScope + ): ChangeAccessForAppsInConversationUseCase = + conversationScope.changeAccessForAppsInConversation } diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt index eb696253dd0..9bb8964155f 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt @@ -27,6 +27,7 @@ import com.wire.kalium.logic.feature.asset.GetPaginatedFlowOfAssetMessageByConve import com.wire.kalium.logic.feature.asset.ObserveAssetStatusesUseCase import com.wire.kalium.logic.feature.asset.ObservePaginatedAssetImageMessages import com.wire.kalium.logic.feature.asset.UpdateAssetMessageTransferStatusUseCase +import com.wire.kalium.logic.feature.asset.UpdateAudioMessageNormalizedLoudnessUseCase import com.wire.kalium.logic.feature.asset.upload.ScheduleNewAssetMessageUseCase import com.wire.kalium.logic.feature.incallreaction.SendInCallReactionUseCase import com.wire.kalium.logic.feature.message.DeleteMessageUseCase @@ -240,4 +241,9 @@ class MessageModule { @Provides fun provideSendMultipartMessageUseCase(messageScope: MessageScope): SendMultipartMessageUseCase = messageScope.sendMultipartMessage + + @ViewModelScoped + @Provides + fun provideUpdateAudioMessageNormalizedLoudnessUseCase(messageScope: MessageScope): UpdateAudioMessageNormalizedLoudnessUseCase = + messageScope.updateAudioMessageNormalizedLoudnessUseCase } 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 c413e789a4c..5e29cda3284 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt @@ -30,6 +30,7 @@ import com.wire.android.ui.home.conversations.model.MessageHeader import com.wire.android.ui.home.conversations.model.MessageSource import com.wire.android.ui.home.conversations.model.MessageStatus import com.wire.android.ui.home.conversations.model.MessageTime +import com.wire.android.ui.home.conversations.model.Reaction import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.home.conversations.model.UIMessageContent import com.wire.android.ui.home.conversations.previewAsset @@ -85,9 +86,13 @@ class MessageMapper @Inject constructor( val footer = if (message is Message.Regular) { MessageFooter( - message.id, - message.reactions.totalReactions, - message.reactions.selfUserReactions + messageId = message.id, + reactionMap = message.reactions.reactions.mapValues { (_, reaction) -> + Reaction( + count = reaction.count, + isSelf = reaction.isSelf + ) + } ) } else { MessageFooter(message.id) diff --git a/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt index 8b2d75c8baa..4ffc5794908 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt @@ -77,6 +77,29 @@ class SystemMessageContentMapper @Inject constructor( is MessageContent.NewConversationWithCellMessage -> UIMessageContent.SystemMessage.NewConversationWithCellStarted is MessageContent.NewConversationWithCellSelfDeleteDisabledMessage -> UIMessageContent.SystemMessage.NewConversationWithCellSelfDeleteDisabled + + is MessageContent.ConversationAppsEnabledChanged -> mapConversationConversationAppsAccessChanged( + message.senderUserId, + content, + members + ) + } + + private fun mapConversationConversationAppsAccessChanged( + senderUserId: UserId, + content: MessageContent.ConversationAppsEnabledChanged, + members: List + ): UIMessageContent.SystemMessage { + val sender = members.findUser(userId = senderUserId) + val authorName = mapMemberName( + user = sender, + type = SelfNameType.ResourceTitleCase + ) + return UIMessageContent.SystemMessage.ConversationAppsEnabledChanged( + author = authorName, + isAuthorSelfUser = sender is SelfUser, + isAccessEnabled = content.isEnabled + ) } private fun mapConversationCreated(senderUserId: UserId, date: Instant, userList: List): UIMessageContent.SystemMessage { @@ -252,7 +275,7 @@ class SystemMessageContentMapper @Inject constructor( FailedToAdd.Type.LegalHold -> UIMessageContent.SystemMessage.MemberFailedToAdd.Type.LegalHold FailedToAdd.Type.Unknown -> UIMessageContent.SystemMessage.MemberFailedToAdd.Type.Unknown } - ) + ) is MemberChange.FederationRemoved -> UIMessageContent.SystemMessage.FederationMemberRemoved( memberNames = memberNameList @@ -289,6 +312,7 @@ class SystemMessageContentMapper @Inject constructor( is OtherUser -> user.name?.let { UIText.DynamicString(it) } ?: UIText.StringResource(messageResourceProvider.memberNameDeleted) + is SelfUser -> when (type) { SelfNameType.ResourceLowercase -> UIText.StringResource(messageResourceProvider.memberNameYouLowercase) SelfNameType.ResourceTitleCase -> UIText.StringResource(messageResourceProvider.memberNameYouTitlecase) diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioMessageViewModel.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioMessageViewModel.kt index 7d3a1e12f8b..dec74bbfa04 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioMessageViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioMessageViewModel.kt @@ -86,7 +86,7 @@ class AudioMessageViewModelImpl @Inject constructor( private fun initWavesMask() { viewModelScope.launch { - audioMessagePlayer.fetchWavesMask(args.conversationId, args.messageId) + audioMessagePlayer.getOrBuildWavesMask(args.conversationId, args.messageId) } } diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt index de43bc8a5e7..ee6cbb28170 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt @@ -17,81 +17,38 @@ */ package com.wire.android.media.audiomessage -import android.content.Context -import dagger.Reusable -import dagger.hilt.android.qualifiers.ApplicationContext -import linc.com.amplituda.Amplituda -import linc.com.amplituda.Cache -import okio.Path -import java.io.File -import javax.inject.Inject import kotlin.math.roundToInt -@Reusable -class AudioWavesMaskHelper @Inject constructor( - @ApplicationContext private val appContext: Context, -) { - - companion object { - private const val WAVES_AMOUNT = 75 - private const val WAVE_MAX = 32 - } - - @Suppress("TooGenericExceptionCaught") - private val amplituda: Lazy = lazy { - try { - Amplituda(appContext) - } catch (e: Throwable) { - null - } - } - - @Suppress("TooGenericExceptionCaught") - private fun getAmplituda(): Amplituda? = amplituda.value - - fun getWaveMask(decodedAssetPath: Path): List? = getWaveMask(File(decodedAssetPath.toString())) - - fun getWaveMask(file: File): List? = getAmplituda() - ?.processAudio(file, Cache.withParams(Cache.REUSE)) - ?.get() - ?.amplitudesAsList() - ?.averageWavesMask() - ?.equalizeWavesMask() - - private fun List.equalizeWavesMask(): List { - if (this.isEmpty()) return listOf() - - val divider = max() / (WAVE_MAX - 1) - - return if (divider == 0.0) { - map { 1 } - } else { - map { (it / divider).roundToInt() + 1 } - } - } - - private fun List.averageWavesMask(): List { - val wavesSize = size - val sectionSize = (wavesSize.toFloat() / WAVES_AMOUNT).roundToInt() - - if (wavesSize < WAVES_AMOUNT || sectionSize == 1) return map { it.toDouble() } - - val averagedWaves = mutableListOf() - for (i in 0..<(wavesSize / sectionSize)) { - val startIndex = (i * sectionSize) - if (startIndex >= wavesSize) continue - val endIndex = (startIndex + sectionSize).coerceAtMost(wavesSize - 1) - averagedWaves.add(subList(startIndex, endIndex).averageInt()) - } - return averagedWaves - } - - private fun List.averageInt(): Double { - if (isEmpty()) return 0.0 - return sum().toDouble() / size +const val WAVE_MAX = 32 + +fun List.equalizedWavesMask( + newMaxValue: Int = WAVE_MAX, + currentMaxValue: Int = UByte.MAX_VALUE.toInt(), // normalized loudness can be up to 255 (UByte.MAX_VALUE) + startFrom1: Boolean = true, // whether the minimum value should be 1 or 0 +): List { + if (this.isEmpty()) return listOf() + val adjustedValue = if (startFrom1) 1 else 0 + val divider = currentMaxValue.toDouble() / (newMaxValue - adjustedValue) + return if (divider == 0.0) { + map { adjustedValue } + } else { + map { (it / divider).roundToInt() + adjustedValue } } +} - fun clear() { - getAmplituda()?.clearCache() +@Suppress("ReturnCount") +fun List.sampledWavesMask(amount: Int): List { + if (this.isEmpty() || amount <= 0) return listOf() + if (amount >= this.size) return this + val res = MutableList(size = amount) { 1 } + for (i in 0..amount - 2) { + val index = i * (size - 1) / (amount - 1) + val p = i * (size - 1) % (amount - 1) + res[i] = ((p * this[index + 1]) + (((amount - 1) - p) * this[index])) / (amount - 1) } + res[amount - 1] = this[size - 1] // done outside of loop to avoid out of bound access (0 * this[size]) + return res } + +fun ByteArray.toWavesMask(): List = this.map { it.toUByte().toInt() }.toList() +fun List.toNormalizedLoudness(): ByteArray = this.map { it.toUByte().toByte() }.toByteArray() 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 358295ff1cc..7af8c3dbe2f 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 @@ -30,8 +30,12 @@ import com.wire.android.util.extension.intervalFlow import com.wire.android.util.ui.UIText import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.message.AssetContent.AssetMetadata +import com.wire.kalium.logic.data.message.MessageContent import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.asset.AudioNormalizedLoudnessBuilder import com.wire.kalium.logic.feature.asset.MessageAssetResult +import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase import com.wire.kalium.logic.feature.message.GetNextAudioMessageInConversationUseCase import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult @@ -58,6 +62,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import okio.Path import javax.inject.Inject import javax.inject.Singleton @@ -67,7 +72,7 @@ class ConversationAudioMessagePlayer @Inject constructor( @ApplicationContext private val context: Context, private val audioMediaPlayer: MediaPlayer, - private val wavesMaskHelper: AudioWavesMaskHelper, + private val audioNormalizedLoudnessBuilder: AudioNormalizedLoudnessBuilder, private val servicesManager: Lazy, private val audioFocusHelper: AudioFocusHelper, @KaliumCoreLogic private val coreLogic: CoreLogic, @@ -306,12 +311,10 @@ class ConversationAudioMessagePlayer audioMediaPlayer.setDataSource(context, Uri.parse(result.decodedAssetPath.toString())) audioMediaPlayer.prepare() - audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.WaveMaskUpdate( - conversationId, - messageId, - wavesMaskHelper.getWaveMask(result.decodedAssetPath) - ) + getOrBuildWavesMask( + conversationId = conversationId, + messageId = messageId, + assetPath = result.decodedAssetPath ) if (position != null) audioMediaPlayer.seekTo(position) @@ -363,24 +366,49 @@ class ConversationAudioMessagePlayer seekToAudioPosition.emit(MessageIdWrapper(conversationId, messageId) to position) } - suspend fun fetchWavesMask(conversationId: ConversationId, messageId: String) { + /** + * Gets waves mask from local database for the audio message or builds a new one if it does not exist and emits the update event. + * @param conversationId The ID of the conversation. + * @param messageId The ID of the message. + * @param assetPath Optional path to the decoded asset. If not provided, it will be fetched. + */ + @Suppress("ReturnCount") + suspend fun getOrBuildWavesMask(conversationId: ConversationId, messageId: String, assetPath: Path? = null) { val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() if (currentAccountResult is CurrentSessionResult.Failure) return val userId = (currentAccountResult as CurrentSessionResult.Success).accountInfo.userId - - val result = getAssetMessage(userId, conversationId, messageId) - - if (result is MessageAssetResult.Success) { + val messageResult = coreLogic.getSessionScope(userId).messages.getMessageById(conversationId, messageId) + if (messageResult is GetMessageByIdUseCase.Result.Failure) return + val messageContent = (messageResult as GetMessageByIdUseCase.Result.Success).message.content + if (messageContent !is MessageContent.Asset) return + if (messageContent.value.metadata !is AssetMetadata.Audio) return + val audioMetadata = messageContent.value.metadata as AssetMetadata.Audio + + val currentNormalizedLoudness: ByteArray? = audioMetadata.normalizedLoudness ?: run { + (assetPath ?: getAssetPath(userId, conversationId, messageId))?.let { assetPath -> + audioNormalizedLoudnessBuilder(assetPath.toString())?.also { + coreLogic.getSessionScope(userId).messages.updateAudioMessageNormalizedLoudnessUseCase( + conversationId = conversationId, + messageId = messageId, + normalizedLoudness = it + ) + } + } + } + currentNormalizedLoudness?.let { audioMessageStateUpdate.emit( AudioMediaPlayerStateUpdate.WaveMaskUpdate( conversationId = conversationId, messageId = messageId, - waveMask = wavesMaskHelper.getWaveMask(result.decodedAssetPath) + waveMask = currentNormalizedLoudness.toWavesMask() ) ) } } + private suspend fun getAssetPath(userId: UserId, conversationId: ConversationId, messageId: String): Path? = + (getAssetMessage(userId, conversationId, messageId) as? MessageAssetResult.Success)?.decodedAssetPath + private suspend fun resumeOrPause(conversationId: ConversationId, messageId: String) { if (audioMediaPlayer.isPlaying) { pause(conversationId, messageId) @@ -533,7 +561,6 @@ class ConversationAudioMessagePlayer internal fun clear() { audioMediaPlayer.reset() - wavesMaskHelper.clear() currentAudioMessageId = null audioMessageStateHistory = emptyMap() servicesManager.get().stopPlayingAudioMessageService() diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/RecordAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/RecordAudioMessagePlayer.kt index 2cde4ada44b..8291b011c29 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/RecordAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/RecordAudioMessagePlayer.kt @@ -21,6 +21,7 @@ import android.content.Context import android.media.MediaPlayer import androidx.core.net.toUri import com.wire.android.di.ApplicationScope +import com.wire.kalium.logic.feature.asset.AudioNormalizedLoudnessBuilder import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow @@ -40,7 +41,7 @@ import javax.inject.Inject class RecordAudioMessagePlayer @Inject constructor( private val context: Context, private val audioMediaPlayer: MediaPlayer, - private val wavesMaskHelper: AudioWavesMaskHelper, + private val audioNormalizedLoudnessBuilder: AudioNormalizedLoudnessBuilder, private val audioFocusHelper: AudioFocusHelper, @ApplicationScope private val scope: CoroutineScope ) { @@ -184,10 +185,10 @@ class RecordAudioMessagePlayer @Inject constructor( audioMediaPlayer.seekTo(position) audioMediaPlayer.start() - wavesMaskHelper.getWaveMask(audioFile)?.let { waveMask -> + audioNormalizedLoudnessBuilder(audioFile.path)?.let { audioMessageStateUpdate.emit( RecordAudioMediaPlayerStateUpdate.WaveMaskUpdate( - waveMask = waveMask + waveMask = it.toWavesMask() ) ) } diff --git a/app/src/main/kotlin/com/wire/android/navigation/TabletStyleHelper.kt b/app/src/main/kotlin/com/wire/android/navigation/TabletStyleHelper.kt index a7cae3370ab..fd11b6734fb 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/TabletStyleHelper.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/TabletStyleHelper.kt @@ -37,8 +37,11 @@ import com.wire.android.ui.destinations.NewConversationFolderScreenDestination import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.destinations.SelfUserProfileScreenDestination import com.wire.android.ui.destinations.ServiceDetailsScreenDestination +import com.wire.android.ui.destinations.UpdateAppsAccessScreenDestination import com.wire.android.ui.theme.isTablet +// Todo(docs): Add ADR about this change introduced in navigation styles for tablets, which requires +// adjusting navigation styles for certain destinations when on tablets when using DestinationStyle.Runtime @Suppress("CyclomaticComplexMethod") @Composable fun AdjustDestinationStylesForTablets() { @@ -53,6 +56,7 @@ fun AdjustDestinationStylesForTablets() { GroupConversationDetailsScreenDestination.style = if (isTablet) DialogNavigation else PopUpNavigationAnimation EditConversationNameScreenDestination.style = if (isTablet) DialogNavigation else SlideNavigationAnimation EditGuestAccessScreenDestination.style = if (isTablet) DialogNavigation else SlideNavigationAnimation + UpdateAppsAccessScreenDestination.style = if (isTablet) DialogNavigation else SlideNavigationAnimation ChannelAccessOnUpdateScreenDestination.style = if (isTablet) DialogNavigation else SlideNavigationAnimation EditSelfDeletingMessagesScreenDestination.style = if (isTablet) DialogNavigation else SlideNavigationAnimation ConversationFoldersScreenDestination.style = if (isTablet) DialogNavigation else SlideNavigationAnimation diff --git a/app/src/main/kotlin/com/wire/android/ui/common/upgradetoapps/UpgradeToGetAppsBanner.kt b/app/src/main/kotlin/com/wire/android/ui/common/upgradetoapps/UpgradeToGetAppsBanner.kt new file mode 100644 index 00000000000..2978fbef982 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/upgradetoapps/UpgradeToGetAppsBanner.kt @@ -0,0 +1,53 @@ +/* + * Wire + * Copyright (C) 2025 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.upgradetoapps + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.common.card.WireOutlinedCard +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.theme.wireDimensions + +@Composable +fun UpgradeToGetAppsBanner( + modifier: Modifier = Modifier +) { + WireOutlinedCard( + title = stringResource(R.string.apps_upgrade_teams_for_apps_banner_title), + textContent = stringResource(R.string.apps_upgrade_teams_for_apps_banner_content), + trailingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_info), + contentDescription = null, + tint = colorsScheme().onBackground + ) + }, + modifier = modifier.padding( + start = MaterialTheme.wireDimensions.spacing8x, + end = MaterialTheme.wireDimensions.spacing8x, + top = MaterialTheme.wireDimensions.spacing8x, + bottom = MaterialTheme.wireDimensions.spacing16x, + ) + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/edit/ShareAssetMenuOption.kt b/app/src/main/kotlin/com/wire/android/ui/edit/ShareAssetMenuOption.kt index 4235c655f53..1cb18e98b07 100644 --- a/app/src/main/kotlin/com/wire/android/ui/edit/ShareAssetMenuOption.kt +++ b/app/src/main/kotlin/com/wire/android/ui/edit/ShareAssetMenuOption.kt @@ -36,3 +36,17 @@ fun ShareAssetMenuOption(onShareAsset: () -> Unit) { onItemClick = onShareAsset ) } + +@Composable +fun SharePublicLinkMenuOption(onShareAsset: () -> Unit) { + MenuBottomSheetItem( + leading = { + MenuItemIcon( + id = com.wire.android.ui.common.R.drawable.ic_link, + contentDescription = stringResource(R.string.content_description_share_the_file), + ) + }, + title = stringResource(com.wire.android.feature.cells.R.string.public_link), + onItemClick = onShareAsset + ) +} 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 970dc2f69f2..57cbfa8ba4e 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 @@ -47,6 +47,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -125,6 +126,7 @@ import com.wire.android.ui.common.error.CoreFailureErrorDialog import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.snackbar.SwipeableSnackbar +import com.wire.android.ui.common.spacers.HorizontalSpace import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.destinations.ConversationScreenDestination import com.wire.android.ui.destinations.GroupConversationDetailsScreenDestination @@ -319,6 +321,7 @@ fun ConversationScreen( mentions = compositionState.selectedMentions.map { it.intoMessageMention() }, + isMultipart = compositionState.isMultipart, ) } } @@ -534,9 +537,9 @@ fun ConversationScreen( } }, onAudioRecorded = { + messageComposerStateHolder.messageCompositionInputStateHolder.showAttachments(false) if (conversationInfoViewModel.conversationInfoViewState.isWireCellEnabled) { - messageAttachmentsViewModel.onFilesSelected(listOf(it.uri)) - messageComposerStateHolder.messageCompositionInputStateHolder.showAttachments(false) + messageAttachmentsViewModel.onAudioRecorded(it.uri, it.audioWavesMask) } else { val bundle = ComposableMessageBundle.AudioMessageBundle(conversationInfoViewModel.conversationId, it) sendMessageViewModel.trySendMessage(bundle) @@ -547,7 +550,7 @@ fun ConversationScreen( .show(DeleteMessageDialogState(deleteForEveryone, messageId, conversationMessagesViewModel.conversationId)) }, onAssetItemClicked = conversationMessagesViewModel::openOrFetchAsset, - onImageFullScreenMode = { message, isSelfMessage -> + onImageFullScreenMode = { message, isSelfMessage, cellAssetId -> with(conversationMessagesViewModel) { navigator.navigate( NavigationCommand( @@ -556,7 +559,8 @@ fun ConversationScreen( messageId = message.header.messageId, isSelfAsset = isSelfMessage, isEphemeral = message.header.messageStatus.expirationStatus is ExpirationStatus.Expirable, - messageOptionsEnabled = true + messageOptionsEnabled = true, + cellAssetId = cellAssetId, ) ) ) @@ -885,7 +889,7 @@ private fun ConversationScreen( onAudioRecorded: (UriAsset) -> Unit, onDeleteMessage: (String, Boolean) -> Unit, onAssetItemClicked: (String) -> Unit, - onImageFullScreenMode: (UIMessage.Regular, Boolean) -> Unit, + onImageFullScreenMode: (UIMessage.Regular, Boolean, String?) -> Unit, onStartCall: () -> Unit, onJoinCall: () -> Unit, onReactionClick: (messageId: String, reactionEmoji: String) -> Unit, @@ -940,6 +944,9 @@ private fun ConversationScreen( }, isInteractionEnabled = messageComposerViewState.interactionAvailability == InteractionAvailability.ENABLED ) + + HorizontalDivider(color = colorsScheme().outline) + ConversationBanner( bannerMessage = bannerMessage, spannedTexts = listOf( @@ -1070,7 +1077,7 @@ private fun ConversationScreenContent( onAttachmentPicked: (UriAsset) -> Unit, onAudioRecorded: (UriAsset) -> Unit, onAssetItemClicked: (String) -> Unit, - onImageFullScreenMode: (UIMessage.Regular, Boolean) -> Unit, + onImageFullScreenMode: (UIMessage.Regular, Boolean, String?) -> Unit, onReactionClicked: (String, String) -> Unit, onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit, onOpenProfile: (String) -> Unit, @@ -1319,7 +1326,8 @@ fun MessageList( MessageGroupDateTime( messageDateTime = serverDate, messageDateTimeGroup = previousGroup, - now = currentTime + now = currentTime, + isBubbleUiEnabled = isBubbleUiEnabled ) } } @@ -1358,7 +1366,8 @@ fun MessageList( MessageGroupDateTime( messageDateTime = serverDate, messageDateTimeGroup = currentGroup, - now = currentTime + now = currentTime, + isBubbleUiEnabled = isBubbleUiEnabled ) } } @@ -1413,7 +1422,8 @@ fun MessageList( private fun MessageGroupDateTime( now: Long, messageDateTime: Date, - messageDateTimeGroup: MessageDateTimeGroup? + messageDateTimeGroup: MessageDateTimeGroup?, + isBubbleUiEnabled: Boolean ) { val context = LocalContext.current @@ -1459,25 +1469,45 @@ private fun MessageGroupDateTime( null -> "" } - Row( - Modifier - .fillMaxWidth() - .padding( - top = dimensions().spacing4x, - bottom = dimensions().spacing8x + if (isBubbleUiEnabled) { + Row( + Modifier + .fillMaxWidth() + .padding(dimensions().spacing16x), + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider(modifier = Modifier.weight(1F), color = colorsScheme().outline) + HorizontalSpace.x4() + Text( + text = timeString.uppercase(Locale.getDefault()), + maxLines = 1, + color = colorsScheme().onBackground, + style = MaterialTheme.wireTypography.label02, ) - .background(color = colorsScheme().divider) - .padding( - top = dimensions().spacing6x, - bottom = dimensions().spacing6x, - start = dimensions().spacing56x + HorizontalSpace.x4() + HorizontalDivider(modifier = Modifier.weight(1F), color = colorsScheme().outline) + } + } else { + Row( + Modifier + .fillMaxWidth() + .padding( + top = dimensions().spacing4x, + bottom = dimensions().spacing8x + ) + .background(color = colorsScheme().divider) + .padding( + top = dimensions().spacing6x, + bottom = dimensions().spacing6x, + start = dimensions().spacing56x + ) + ) { + Text( + text = timeString.uppercase(Locale.getDefault()), + color = colorsScheme().secondaryText, + style = MaterialTheme.wireTypography.title03, ) - ) { - Text( - text = timeString.uppercase(Locale.getDefault()), - color = colorsScheme().secondaryText, - style = MaterialTheme.wireTypography.title03, - ) + } } } @@ -1632,7 +1662,7 @@ fun PreviewConversationScreen() = WireTheme { onPingOptionClicked = { }, onDeleteMessage = { _, _ -> }, onAssetItemClicked = { }, - onImageFullScreenMode = { _, _ -> }, + onImageFullScreenMode = { _, _, _ -> }, onStartCall = { }, onJoinCall = { }, onReactionClick = { _, _ -> }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/attachment/MessageAttachmentsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/attachment/MessageAttachmentsViewModel.kt index a9a39dad846..b9d903966e5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/attachment/MessageAttachmentsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/attachment/MessageAttachmentsViewModel.kt @@ -27,6 +27,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.appLogger +import com.wire.android.media.audiomessage.toNormalizedLoudness import com.wire.android.ui.common.attachmentdraft.model.AttachmentDraftUi import com.wire.android.ui.common.attachmentdraft.model.toUiModel import com.wire.android.ui.home.conversations.ConversationNavArgs @@ -37,6 +38,7 @@ import com.wire.android.ui.navArgs import com.wire.android.ui.sharing.ImportedMediaAsset import com.wire.android.util.FileManager import com.wire.android.util.MediaMetadata +import com.wire.android.util.getAudioLengthInMs import com.wire.kalium.cells.domain.CellUploadEvent import com.wire.kalium.cells.domain.CellUploadManager import com.wire.kalium.cells.domain.model.AttachmentDraft @@ -48,6 +50,7 @@ import com.wire.kalium.cells.domain.usecase.RetryAttachmentUploadUseCase import com.wire.kalium.common.functional.onFailure import com.wire.kalium.common.functional.onSuccess import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.message.AssetContent import com.wire.kalium.logic.util.fileExtension import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -104,6 +107,24 @@ class MessageAttachmentsViewModel @Inject constructor( } } + fun onAudioRecorded(uri: Uri, wavesMask: List?) = viewModelScope.launch { + handleImportedAsset(uri)?.assetBundle?.let { bundle -> + addAttachment( + conversationId = conversationId, + fileName = bundle.fileName, + assetPath = bundle.dataPath, + assetSize = bundle.dataSize, + mimeType = bundle.mimeType, + assetMetadata = AssetContent.AssetMetadata.Audio( + durationMs = getAudioLengthInMs(bundle.dataPath, bundle.mimeType), + normalizedLoudness = wavesMask?.toNormalizedLoudness() + ) + ).onFailure { + appLogger.e("Failed to add recorded audio attachment: $it", tag = "MessageAttachmentsViewModel") + } + } + } + fun onFilesSelected(uriList: List) = viewModelScope.launch { uriList.forEach { uri -> handleImportedAsset(uri)?.let { asset -> 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 7dbd59ca41a..c465681d5d0 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 @@ -103,6 +103,7 @@ import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.destinations.SearchConversationMessagesScreenDestination import com.wire.android.ui.destinations.SelfUserProfileScreenDestination import com.wire.android.ui.destinations.ServiceDetailsScreenDestination +import com.wire.android.ui.destinations.UpdateAppsAccessScreenDestination import com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessParams import com.wire.android.ui.home.conversations.details.options.GroupConversationOptions import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsState @@ -110,6 +111,7 @@ import com.wire.android.ui.home.conversations.details.options.LoadingGroupConver import com.wire.android.ui.home.conversations.details.participants.GroupConversationParticipants import com.wire.android.ui.home.conversations.details.participants.GroupConversationParticipantsState import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant +import com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessParams import com.wire.android.ui.home.conversations.details.updatechannelaccess.UpdateChannelAccessArgs import com.wire.android.ui.home.conversations.folder.ConversationFoldersNavArgs import com.wire.android.ui.home.conversations.folder.ConversationFoldersNavBackArgs @@ -201,8 +203,9 @@ fun GroupConversationDetailsScreen( navigator.navigate( NavigationCommand( AddMembersSearchScreenDestination( - viewModel.conversationId, - groupOptions.isServicesAllowed + conversationId = viewModel.conversationId, + isConversationAppsEnabled = groupOptions.isAppsAllowed, + isSelfPartOfATeam = groupOptions.isSelfPartOfATeam ) ) ) @@ -215,13 +218,26 @@ fun GroupConversationDetailsScreen( viewModel.conversationId, EditGuestAccessParams( groupOptions.isGuestAllowed, - groupOptions.isServicesAllowed, + groupOptions.isAppsAllowed, groupOptions.isUpdatingGuestAllowed ) ) ) ) }, + onAppsAccessItemClicked = { + navigator.navigate( + NavigationCommand( + UpdateAppsAccessScreenDestination( + viewModel.conversationId, + UpdateAppsAccessParams( + isGuestAllowed = groupOptions.isGuestAllowed, + isAppsAllowed = groupOptions.isAppsAllowed + ) + ) + ) + ) + }, onChannelAccessItemClicked = { navigator.navigate( NavigationCommand( @@ -327,11 +343,12 @@ private fun GroupConversationDetailsContent( onProfilePressed: (UIParticipant) -> Unit, onAddParticipantsPressed: () -> Unit, onEditGuestAccess: () -> Unit, + onAppsAccessItemClicked: () -> Unit, onChannelAccessItemClicked: () -> Unit, onEditSelfDeletingMessages: () -> Unit, onEditGroupName: () -> Unit, groupParticipantsState: GroupConversationParticipantsState, - showAllowUserToAddParticipants: () -> (Boolean), + showAllowUserToAddParticipants: () -> Boolean, isAbandonedOneOnOneConversation: Boolean, isWireCellEnabled: Boolean, onSearchConversationMessagesClick: () -> Unit, @@ -493,6 +510,7 @@ private fun GroupConversationDetailsContent( GroupConversationDetailsTabItem.OPTIONS -> GroupConversationOptions( lazyListState = lazyListStates[pageIndex], onEditGuestAccess = onEditGuestAccess, + onAppsAccessItemClicked = onAppsAccessItemClicked, onChannelAccessItemClicked = onChannelAccessItemClicked, onEditSelfDeletingMessages = onEditSelfDeletingMessages, onEditGroupName = onEditGroupName @@ -595,25 +613,26 @@ enum class GroupConversationDetailsTabItem(@StringRes val titleResId: Int) : Tab fun PreviewGroupConversationDetails() { WireTheme { GroupConversationDetailsContent( - sheetState = rememberWireModalSheetState(), - initialPageIndex = GroupConversationDetailsTabItem.PARTICIPANTS, groupConversationOptionsState = GroupConversationOptionsState(ConversationId("v", "d")), - showAllowUserToAddParticipants = { true }, + sheetState = rememberWireModalSheetState(), onBackPressed = {}, onProfilePressed = {}, onAddParticipantsPressed = {}, - groupParticipantsState = GroupConversationParticipantsState.PREVIEW, - onEditGroupName = {}, - onEditSelfDeletingMessages = {}, onEditGuestAccess = {}, + onAppsAccessItemClicked = {}, onChannelAccessItemClicked = {}, - onSearchConversationMessagesClick = {}, - onConversationMediaClick = {}, + onEditSelfDeletingMessages = {}, + onEditGroupName = {}, + groupParticipantsState = GroupConversationParticipantsState.PREVIEW, + showAllowUserToAddParticipants = { true }, isAbandonedOneOnOneConversation = false, isWireCellEnabled = false, + onSearchConversationMessagesClick = {}, + onConversationMediaClick = {}, onMoveToFolder = {}, onLeftConversation = {}, onDeletedConversation = {}, + initialPageIndex = GroupConversationDetailsTabItem.PARTICIPANTS ) } } 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 3d949b27c80..73bb5175f0d 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 @@ -35,16 +35,13 @@ import com.wire.android.util.ui.UIText import com.wire.kalium.common.functional.getOrNull import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationDetails -import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.user.type.isExternal import com.wire.kalium.logic.data.user.type.isTeamAdmin import com.wire.kalium.logic.feature.client.IsWireCellsEnabledUseCase import com.wire.kalium.logic.feature.conversation.ConversationUpdateReceiptModeResult import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase -import com.wire.kalium.logic.feature.conversation.UpdateConversationAccessRoleUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationReceiptModeUseCase -import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppsAllowedForUsageUseCase import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.team.GetUpdatedSelfTeamUseCase @@ -71,7 +68,6 @@ class GroupConversationDetailsViewModel @Inject constructor( private val dispatcher: DispatcherProvider, private val observeConversationDetails: ObserveConversationDetailsUseCase, observeConversationMembers: ObserveParticipantsForConversationUseCase, - private val updateConversationAccessRole: UpdateConversationAccessRoleUseCase, private val getSelfTeam: GetUpdatedSelfTeamUseCase, private val getSelfUser: GetSelfUserUseCase, private val updateConversationReceiptMode: UpdateConversationReceiptModeUseCase, @@ -80,7 +76,6 @@ class GroupConversationDetailsViewModel @Inject constructor( private val isMLSEnabled: IsMLSEnabledUseCase, refreshUsersWithoutMetadata: RefreshUsersWithoutMetadataUseCase, private val isWireCellsEnabled: IsWireCellsEnabledUseCase, - private val observeIsAppsAllowedForUsage: ObserveIsAppsAllowedForUsageUseCase, ) : GroupConversationParticipantsViewModel(savedStateHandle, observeConversationMembers, refreshUsersWithoutMetadata), ActionsManager by ActionsManagerImpl() { @@ -118,10 +113,9 @@ class GroupConversationDetailsViewModel @Inject constructor( val selfUser = getSelfUser() combine( - observeIsAppsAllowedForUsage(), groupDetailsFlow, observeSelfDeletionTimerSettingsForConversation(conversationId, considerSelfUserSettings = false), - ) { isAppsUsageAllowed, groupDetails, selfDeletionTimer -> + ) { groupDetails, selfDeletionTimer -> val isSelfInTeamThatOwnsConversation = selfTeam?.id != null && selfTeam.id == groupDetails.conversation.teamId?.value val isSelfExternalMember = selfUser?.userType?.isExternal() == true val isChannel = groupDetails is ConversationDetails.Group.Channel @@ -146,8 +140,8 @@ class GroupConversationDetailsViewModel @Inject constructor( isUpdatingNameAllowed = canSelfPerformAdminTasks && !isSelfExternalMember, isUpdatingGuestAllowed = canSelfPerformAdminTasks && isSelfInTeamThatOwnsConversation, isUpdatingChannelAccessAllowed = canSelfPerformAdminTasks && isSelfInTeamThatOwnsConversation, - isServicesAllowed = groupDetails.conversation.isServicesAllowed() && isAppsUsageAllowed, - isUpdatingServicesAllowed = canSelfPerformAdminTasks && isAppsUsageAllowed, + isAppsAllowed = groupDetails.conversation.isServicesAllowed(), + isUpdatingAppsAllowed = canSelfPerformAdminTasks && isSelfInTeamThatOwnsConversation, isUpdatingReadReceiptAllowed = canSelfPerformAdminTasks && groupDetails.conversation.isTeamGroup(), isUpdatingSelfDeletingAllowed = canSelfPerformAdminTasks, mlsEnabled = isMLSEnabled(), @@ -160,6 +154,7 @@ class GroupConversationDetailsViewModel @Inject constructor( loadingWireCellState = false, isWireCellEnabled = groupDetails.wireCell != null, isWireCellFeatureEnabled = isWireCellsEnabled(), + isSelfPartOfATeam = selfTeam != null, ) ) }.collect {} @@ -203,60 +198,12 @@ class GroupConversationDetailsViewModel @Inject constructor( updateState(groupOptionsState.value.copy(channelAddPermissionType = channelAddPermissionType)) } - fun onServicesUpdate(enableServices: Boolean) { - updateState(groupOptionsState.value.copy(loadingServicesOption = true, isServicesAllowed = enableServices)) - when (enableServices) { - true -> updateServicesRemoteRequest(enableServices) - false -> updateState(groupOptionsState.value.copy(changeServiceOptionConfirmationRequired = true)) - } - } - fun onReadReceiptUpdate(enableReadReceipt: Boolean) { appLogger.i("[$TAG][onReadReceiptUpdate] - enableReadReceipt: $enableReadReceipt") updateState(groupOptionsState.value.copy(loadingReadReceiptOption = true, isReadReceiptAllowed = enableReadReceipt)) updateReadReceiptRemoteRequest(enableReadReceipt) } - fun onServiceDialogDismiss() { - updateState( - groupOptionsState.value.copy( - loadingServicesOption = false, - changeServiceOptionConfirmationRequired = false, - isServicesAllowed = !groupOptionsState.value.isServicesAllowed - ) - ) - } - - fun onServiceDialogConfirm() { - updateState(groupOptionsState.value.copy(changeServiceOptionConfirmationRequired = false, loadingServicesOption = true)) - updateServicesRemoteRequest(false) - } - - private fun updateServicesRemoteRequest(enableServices: Boolean) { - viewModelScope.launch { - val result = withContext(dispatcher.io()) { - updateConversationAccess( - enableGuestAndNonTeamMember = groupOptionsState.value.isGuestAllowed, - enableServices = enableServices, - conversationId = conversationId - ) - } - - when (result) { - is UpdateConversationAccessRoleUseCase.Result.Failure -> updateState( - groupOptionsState.value.copy( - isServicesAllowed = !enableServices, - error = GroupConversationOptionsState.Error.UpdateServicesError(result.cause) - ) - ) - - is UpdateConversationAccessRoleUseCase.Result.Success -> Unit - } - - updateState(groupOptionsState.value.copy(loadingServicesOption = false)) - } - } - private fun updateReadReceiptRemoteRequest(enableReadReceipt: Boolean) { viewModelScope.launch { val result = withContext(dispatcher.io()) { @@ -284,37 +231,10 @@ class GroupConversationDetailsViewModel @Inject constructor( } } - private suspend fun updateConversationAccess( - enableGuestAndNonTeamMember: Boolean, - enableServices: Boolean, - conversationId: ConversationId - ): UpdateConversationAccessRoleUseCase.Result { - - val accessRoles = Conversation - .accessRolesFor( - guestAllowed = enableGuestAndNonTeamMember, - servicesAllowed = enableServices, - nonTeamMembersAllowed = enableGuestAndNonTeamMember - ) - - val access = Conversation - .accessFor( - guestsAllowed = enableGuestAndNonTeamMember - ) - - return updateConversationAccessRole( - conversationId = conversationId, - accessRoles = accessRoles, - access = access - ) - } - fun updateState(newState: GroupConversationOptionsState) { _groupOptionsState.value = newState } - private fun onMessage(text: UIText) = sendAction(GroupConversationDetailsViewAction.Message(text)) - companion object { const val TAG = "GroupConversationDetailsViewModel" } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/Dialogs.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/Dialogs.kt index f548cc55ca4..0bc278bd9f9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/Dialogs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/Dialogs.kt @@ -25,11 +25,11 @@ import com.wire.android.R import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType -import com.wire.android.ui.home.conversations.details.options.DisableConformationDialog +import com.wire.android.ui.home.conversations.details.options.DisableConfirmationDialog @Composable fun RevokeGuestConfirmationDialog(onConfirm: () -> Unit, onDialogDismiss: () -> Unit) { - DisableConformationDialog( + DisableConfirmationDialog( text = R.string.revoke_guest__room_link_dialog_text, title = R.string.revoke_guest__room_link_dialog_title, onConfirm = onConfirm, @@ -39,7 +39,7 @@ fun RevokeGuestConfirmationDialog(onConfirm: () -> Unit, onDialogDismiss: () -> @Composable fun DisableGuestConfirmationDialog(onConfirm: () -> Unit, onDialogDismiss: () -> Unit) { - DisableConformationDialog( + DisableConfirmationDialog( text = R.string.disable_guest_dialog_text, title = R.string.disable_guest_dialog_title, onConfirm = onConfirm, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptions.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptions.kt index d67b9fa2317..cc33723a951 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptions.kt @@ -68,6 +68,7 @@ import kotlin.time.Duration.Companion.days fun GroupConversationOptions( lazyListState: LazyListState, onEditGuestAccess: () -> Unit, + onAppsAccessItemClicked: () -> Unit, onChannelAccessItemClicked: () -> Unit, onEditSelfDeletingMessages: () -> Unit, viewModel: GroupConversationDetailsViewModel = hiltViewModel(), @@ -78,20 +79,13 @@ fun GroupConversationOptions( GroupConversationSettings( state = state, onGuestItemClicked = onEditGuestAccess, + onAppsAccessItemClicked = onAppsAccessItemClicked, onSelfDeletingClicked = onEditSelfDeletingMessages, onChannelAccessItemClicked = onChannelAccessItemClicked, - onServiceSwitchClicked = viewModel::onServicesUpdate, onReadReceiptSwitchClicked = viewModel::onReadReceiptUpdate, lazyListState = lazyListState, onEditGroupName = onEditGroupName, ) - - if (state.changeServiceOptionConfirmationRequired) { - DisableServicesConfirmationDialog( - onConfirm = viewModel::onServiceDialogConfirm, - onDialogDismiss = viewModel::onServiceDialogDismiss - ) - } } @Composable @@ -99,8 +93,8 @@ fun GroupConversationSettings( state: GroupConversationOptionsState, onChannelAccessItemClicked: () -> Unit, onGuestItemClicked: () -> Unit, + onAppsAccessItemClicked: () -> Unit, onSelfDeletingClicked: () -> Unit, - onServiceSwitchClicked: (Boolean) -> Unit, onReadReceiptSwitchClicked: (Boolean) -> Unit, onEditGroupName: () -> Unit, modifier: Modifier = Modifier, @@ -152,11 +146,16 @@ fun GroupConversationSettings( ) } add { - ServicesOption( - isSwitchEnabledAndVisible = state.isUpdatingServicesAllowed, - switchState = state.isServicesAllowed, - isLoading = state.loadingServicesOption, - onCheckedChange = onServiceSwitchClicked + GroupConversationOptionsItem( + title = stringResource(id = R.string.conversation_options_services_label), + subtitle = stringResource(id = R.string.conversation_options_services_description), + switchState = SwitchState.TextOnly(value = state.isAppsAllowed), + arrowType = if (state.isUpdatingAppsAllowed) ArrowType.TITLE_ALIGNED else ArrowType.NONE, + clickable = Clickable( + enabled = state.isUpdatingAppsAllowed, + onClick = onAppsAccessItemClicked, + onClickDescription = stringResource(id = R.string.content_description_conversation_details_apps_action) + ), ) } addIf(state.isWireCellEnabled) { @@ -326,24 +325,6 @@ private fun ProtocolDetails(label: UIText, text: UIText) { HorizontalDivider(thickness = Dp.Hairline, color = MaterialTheme.wireColorScheme.divider) } -@Composable -private fun ServicesOption( - isSwitchEnabledAndVisible: Boolean, - switchState: Boolean, - isLoading: Boolean, - onCheckedChange: (Boolean) -> Unit -) { - GroupOptionWithSwitch( - switchClickable = isSwitchEnabledAndVisible, - switchVisible = isSwitchEnabledAndVisible, - switchState = switchState, - isLoading = isLoading, - onClick = onCheckedChange, - title = R.string.conversation_options_services_label, - subTitle = if (isSwitchEnabledAndVisible) R.string.conversation_options_services_description else null - ) -} - @Composable private fun ReadReceiptOption( isSwitchEnabled: Boolean, @@ -386,17 +367,7 @@ fun GroupOptionWithSwitch( } @Composable -private fun DisableServicesConfirmationDialog(onConfirm: () -> Unit, onDialogDismiss: () -> Unit) { - DisableConformationDialog( - title = R.string.disable_services_dialog_title, - text = R.string.disable_services_dialog_text, - onDismiss = onDialogDismiss, - onConfirm = onConfirm - ) -} - -@Composable -fun DisableConformationDialog(@StringRes title: Int, @StringRes text: Int, onConfirm: () -> Unit, onDismiss: () -> Unit) { +fun DisableConfirmationDialog(@StringRes title: Int, @StringRes text: Int, onConfirm: () -> Unit, onDismiss: () -> Unit) { WireDialog( title = stringResource(id = title), text = stringResource(id = text), @@ -419,7 +390,7 @@ private val StateMember = GroupConversationOptionsState( groupName = "Conversation Name", areAccessOptionsAvailable = true, isGuestAllowed = true, - isServicesAllowed = true, + isAppsAllowed = true, isReadReceiptAllowed = true, ) @@ -427,7 +398,7 @@ private val StateAdmin = StateMember.copy( isUpdatingNameAllowed = true, isUpdatingGuestAllowed = true, isUpdatingChannelAccessAllowed = true, - isUpdatingServicesAllowed = true, + isUpdatingAppsAllowed = true, isUpdatingSelfDeletingAllowed = true, isUpdatingReadReceiptAllowed = true, ) @@ -448,12 +419,12 @@ private fun PreviewGroupConversationOptions(state: GroupConversationOptionsState onChannelAccessItemClicked = {}, onGuestItemClicked = {}, onSelfDeletingClicked = {}, - onServiceSwitchClicked = {}, + onAppsAccessItemClicked = {}, onReadReceiptSwitchClicked = {}, onEditGroupName = {}, modifier = Modifier, lazyListState = rememberLazyListState(), - mlsReadReceiptsEnabled = false, + mlsReadReceiptsEnabled = false ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptionsState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptionsState.kt index 85b9e1e3baf..b954d7f8857 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptionsState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptionsState.kt @@ -47,18 +47,16 @@ data class GroupConversationOptionsState( val legalHoldStatus: Conversation.LegalHoldStatus = Conversation.LegalHoldStatus.DISABLED, val areAccessOptionsAvailable: Boolean = false, val isGuestAllowed: Boolean = false, - val isServicesAllowed: Boolean = false, + val isAppsAllowed: Boolean = false, val isReadReceiptAllowed: Boolean = false, val isUpdatingNameAllowed: Boolean = false, val isUpdatingGuestAllowed: Boolean = false, - val isUpdatingServicesAllowed: Boolean = false, + val isUpdatingAppsAllowed: Boolean = false, val isUpdatingSelfDeletingAllowed: Boolean = false, val isUpdatingReadReceiptAllowed: Boolean = false, val isUpdatingChannelAccessAllowed: Boolean = false, val shouldShowAddParticipantsButtonForChannel: Boolean = false, val changeGuestOptionConfirmationRequired: Boolean = false, - val changeServiceOptionConfirmationRequired: Boolean = false, - val loadingServicesOption: Boolean = false, val loadingReadReceiptOption: Boolean = false, val isChannel: Boolean = false, val isSelfTeamAdmin: Boolean = false, @@ -70,11 +68,11 @@ data class GroupConversationOptionsState( val loadingWireCellState: Boolean = false, val isWireCellFeatureEnabled: Boolean = false, val isWireCellEnabled: Boolean = false, + val isSelfPartOfATeam: Boolean = false, ) { sealed interface Error { data object None : Error - class UpdateServicesError(val cause: CoreFailure) : Error class UpdateReadReceiptError(val cause: CoreFailure) : Error } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationAllParticipantsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationAllParticipantsScreen.kt index f57861f6816..d25838e1bc2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationAllParticipantsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationAllParticipantsScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -44,6 +45,7 @@ import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.destinations.SelfUserProfileScreenDestination import com.wire.android.ui.destinations.ServiceDetailsScreenDestination +import com.wire.android.ui.home.conversations.details.participants.model.ParticipantsExpansionState import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant import com.wire.android.ui.theme.WireTheme @@ -64,6 +66,7 @@ fun GroupConversationAllParticipantsScreen( participant.isSelf -> navigator.navigate(NavigationCommand(SelfUserProfileScreenDestination)) participant.isService && participant.botService != null -> navigator.navigate(NavigationCommand(ServiceDetailsScreenDestination(participant.botService, navArgs.conversationId))) + else -> navigator.navigate(NavigationCommand(OtherUserProfileScreenDestination(participant.id, navArgs.conversationId))) } }, @@ -78,6 +81,7 @@ private fun GroupConversationAllParticipantsContent( groupParticipantsState: GroupConversationParticipantsState ) { val lazyListState: LazyListState = rememberLazyListState() + val participantsExpansionState = remember { ParticipantsExpansionState() } WireScaffold( topBar = { WireCenterAlignedTopAppBar( @@ -95,9 +99,16 @@ private fun GroupConversationAllParticipantsContent( val context = LocalContext.current LazyColumn( state = lazyListState, - modifier = Modifier.fillMaxWidth().padding(internalPadding) + modifier = Modifier + .fillMaxWidth() + .padding(internalPadding) ) { - participantsFoldersWithElements(context, groupParticipantsState, onProfilePressed) + participantsFoldersWithElements( + context, + groupParticipantsState, + onProfilePressed, + participantsExpansionState + ) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantList.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantList.kt index a8a75b7e4a3..31c4d797a69 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantList.kt @@ -25,31 +25,46 @@ import androidx.compose.ui.res.stringResource import com.wire.android.R import com.wire.android.model.Clickable import com.wire.android.ui.common.divider.WireDivider +import com.wire.android.ui.home.conversations.details.participants.model.MemberSectionActions +import com.wire.android.ui.home.conversations.details.participants.model.ParticipantsExpansionState import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant +import com.wire.android.util.ui.FolderType import com.wire.android.util.ui.sectionWithElements fun LazyListScope.participantsFoldersWithElements( context: Context, state: GroupConversationParticipantsState, - onRowItemClicked: (UIParticipant) -> Unit + onRowItemClicked: (UIParticipant) -> Unit, + participantsExpansionState: ParticipantsExpansionState ) { sectionWithElements( header = context.getString(R.string.conversation_details_conversation_admins, state.data.allAdminsCount), items = state.data.admins, - onRowItemClicked = onRowItemClicked + onRowItemClicked = onRowItemClicked, + sectionActions = participantsExpansionState.adminsActions ) sectionWithElements( header = context.getString(R.string.conversation_details_conversation_members, state.data.allParticipantsCount), items = state.data.participants, - onRowItemClicked = onRowItemClicked + onRowItemClicked = onRowItemClicked, + sectionActions = participantsExpansionState.membersActions ) + if (state.data.allAppsCount > 0) { + sectionWithElements( + header = context.getString(R.string.conversation_details_conversation_apps, state.data.allAppsCount), + items = state.data.apps, + onRowItemClicked = onRowItemClicked, + sectionActions = participantsExpansionState.appsActions + ) + } } fun LazyListScope.sectionWithElements( header: String, items: List, onRowItemClicked: (UIParticipant) -> Unit, - showRightArrow: Boolean = true + showRightArrow: Boolean = true, + sectionActions: MemberSectionActions ) = sectionWithElements( header = header, items = items.associateBy { it.id.toString() }, @@ -62,6 +77,15 @@ fun LazyListScope.sectionWithElements( showRightArrow = showRightArrow ) }, + folderType = when (sectionActions) { + MemberSectionActions.NoActions -> FolderType.Regular + is MemberSectionActions.WithSectionActions -> { + FolderType.Collapsing( + expanded = sectionActions.expanded.value, + onChanged = sectionActions.onExpansionChanged + ) + } + }, divider = { WireDivider() } ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipants.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipants.kt index 86a4cf068d4..5434a85ae1e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipants.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipants.kt @@ -31,12 +31,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import com.wire.android.BuildConfig import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.progress.WireLinearProgressIndicator +import com.wire.android.ui.home.conversations.details.participants.model.ParticipantsExpansionState import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme @@ -53,6 +55,7 @@ fun GroupConversationParticipants( modifier: Modifier = Modifier ) { val context = LocalContext.current + val participantsExpansionState = remember { ParticipantsExpansionState() } Column(modifier = modifier) { LazyColumn( state = lazyListState, @@ -77,7 +80,7 @@ fun GroupConversationParticipants( } } } - participantsFoldersWithElements(context, groupParticipantsState, onProfilePressed) + participantsFoldersWithElements(context, groupParticipantsState, onProfilePressed, participantsExpansionState) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/model/ConversationParticipantsData.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/model/ConversationParticipantsData.kt index 6d97a640750..bdf9c032b50 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/model/ConversationParticipantsData.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/model/ConversationParticipantsData.kt @@ -21,12 +21,14 @@ package com.wire.android.ui.home.conversations.details.participants.model data class ConversationParticipantsData( val admins: List = listOf(), val participants: List = listOf(), + val apps: List = listOf(), val allAdminsCount: Int = 0, val allParticipantsCount: Int = 0, + val allAppsCount: Int = 0, val isSelfAnAdmin: Boolean = false, val isSelfExternalMember: Boolean = false, val isSelfGuest: Boolean = false, ) { - val allCount: Int = allAdminsCount + allParticipantsCount - val allParticipants: List = participants + admins + val allCount: Int = allAdminsCount + allParticipantsCount + allAppsCount + val allParticipants: List = participants + admins + apps } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/model/ParticipantsExpansionState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/model/ParticipantsExpansionState.kt new file mode 100644 index 00000000000..b9d86057322 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/model/ParticipantsExpansionState.kt @@ -0,0 +1,47 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.details.participants.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf + +@Stable +class ParticipantsExpansionState { + private val membersExpanded: MutableState = mutableStateOf(true) + private val adminsExpanded: MutableState = mutableStateOf(true) + private val appsExpanded: MutableState = mutableStateOf(true) + + val membersActions = + MemberSectionActions.WithSectionActions(membersExpanded) { membersExpanded.value = it } + val adminsActions = + MemberSectionActions.WithSectionActions(adminsExpanded) { adminsExpanded.value = it } + val appsActions = + MemberSectionActions.WithSectionActions(appsExpanded) { appsExpanded.value = it } +} + +sealed class MemberSectionActions { + @Stable + data class WithSectionActions( + val expanded: MutableState, + val onExpansionChanged: (Boolean) -> Unit + ) : MemberSectionActions() + + @Stable + data object NoActions : MemberSectionActions() +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/usecase/ObserveParticipantsForConversationUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/usecase/ObserveParticipantsForConversationUseCase.kt index cdf65dad3a1..100ee28a4cd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/usecase/ObserveParticipantsForConversationUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/usecase/ObserveParticipantsForConversationUseCase.kt @@ -52,16 +52,30 @@ class ObserveParticipantsForConversationUseCase @Inject constructor( memberDetailList.sortedBy { it.name }.groupBy { val isAdmin = it.role == Member.Role.Admin val isService = (it.user as? OtherUser)?.userType?.isAppOrBot() == true - isAdmin && !isService + when { + isService -> { + MemberSectionType.APP + } + + isAdmin && !isService -> { + MemberSectionType.ADMIN + } + + else -> { + MemberSectionType.PARTICIPANT + } + } } } .scan( ConversationParticipantsData() to emptyMap() ) { (_, previousMlsVerificationMap), sortedMemberList -> - val allAdminsWithoutServices = sortedMemberList.getOrDefault(true, listOf()) + val allAdminsWithoutServices = sortedMemberList.getOrDefault(MemberSectionType.ADMIN, listOf()) val visibleAdminsWithoutServices = allAdminsWithoutServices.limit(limit) - val allParticipants = sortedMemberList.getOrDefault(false, listOf()) + val allParticipants = sortedMemberList.getOrDefault(MemberSectionType.PARTICIPANT, listOf()) val visibleParticipants = allParticipants.limit(limit) + val allApps = sortedMemberList.getOrDefault(MemberSectionType.APP, listOf()) + val visibleApps = allApps.limit(limit) val visibleUserIds = visibleParticipants.map { it.userId } .plus(visibleAdminsWithoutServices.map { it.userId }) @@ -76,15 +90,17 @@ class ObserveParticipantsForConversationUseCase @Inject constructor( } ) - val selfUser = (allParticipants + allAdminsWithoutServices).firstOrNull { it.user is SelfUser } + val selfUser = (allParticipants + allAdminsWithoutServices + allApps).firstOrNull { it.user is SelfUser } ConversationParticipantsData( admins = visibleAdminsWithoutServices .map { uiParticipantMapper.toUIParticipant(it.user, mlsVerificationMap[it.user.id] ?: false) }, participants = visibleParticipants .map { uiParticipantMapper.toUIParticipant(it.user, mlsVerificationMap[it.user.id] ?: false) }, + apps = visibleApps.map { uiParticipantMapper.toUIParticipant(it.user, mlsVerificationMap[it.user.id] ?: false) }, allAdminsCount = allAdminsWithoutServices.size, allParticipantsCount = allParticipants.size, + allAppsCount = allApps.size, isSelfAnAdmin = allAdminsWithoutServices.any { it.user is SelfUser }, isSelfExternalMember = selfUser?.user?.userType?.isExternal() == true, isSelfGuest = selfUser?.user?.userType?.isGuest() == true, @@ -99,4 +115,10 @@ class ObserveParticipantsForConversationUseCase @Inject constructor( limit == 0 -> listOf() else -> this } + + enum class MemberSectionType { + ADMIN, + PARTICIPANT, + APP + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessNavArgs.kt new file mode 100644 index 00000000000..c91f44f7b0c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessNavArgs.kt @@ -0,0 +1,25 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.details.updateappsaccess + +import com.wire.kalium.logic.data.id.ConversationId + +data class UpdateAppsAccessNavArgs( + val conversationId: ConversationId, + val updateAppsAccessParams: UpdateAppsAccessParams +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessParams.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessParams.kt new file mode 100644 index 00000000000..42aeab997ca --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessParams.kt @@ -0,0 +1,26 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.details.updateappsaccess + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateAppsAccessParams( + val isGuestAllowed: Boolean, + val isAppsAllowed: Boolean, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessScreen.kt new file mode 100644 index 00000000000..a54d3509388 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessScreen.kt @@ -0,0 +1,181 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.details.updateappsaccess + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.ramcosta.composedestinations.spec.DestinationStyle +import com.wire.android.R +import com.wire.android.navigation.Navigator +import com.wire.android.navigation.annotation.app.WireDestination +import com.wire.android.ui.common.rememberTopBarElevationState +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState +import com.wire.android.ui.common.topappbar.NavigationIconType +import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.common.upgradetoapps.UpgradeToGetAppsBanner +import com.wire.android.ui.home.conversations.details.options.DisableConfirmationDialog +import com.wire.android.ui.home.conversations.details.options.GroupOptionWithSwitch +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.util.ui.PreviewMultipleThemes +import kotlinx.coroutines.launch + +@WireDestination( + navArgsDelegate = UpdateAppsAccessNavArgs::class, + style = DestinationStyle.Runtime::class, +) +@Composable +fun UpdateAppsAccessScreen( + navigator: Navigator, + updateAppsAccessViewModel: UpdateAppsAccessViewModel = hiltViewModel() +) { + UpdateAppsAccessContent( + onNavigateBack = navigator::navigateBack, + onChangeAppAccess = updateAppsAccessViewModel::onAppsAccessUpdate, + onDisableAppsConfirm = updateAppsAccessViewModel::onServiceDialogConfirm, + onDisableAppsDismiss = updateAppsAccessViewModel::onAppsDialogDismiss, + state = updateAppsAccessViewModel.updateAppsAccessState, + ) +} + +@Composable +private fun UpdateAppsAccessContent( + onNavigateBack: () -> Unit, + onChangeAppAccess: (Boolean) -> Unit, + onDisableAppsConfirm: () -> Unit, + onDisableAppsDismiss: () -> Unit, + state: UpdateAppsAccessState, + modifier: Modifier = Modifier +) { + val snackbarHostState = LocalSnackbarHostState.current + val coroutineScope = rememberCoroutineScope() + val scrollState = rememberScrollState() + WireScaffold( + modifier = modifier, + topBar = { + val title = stringResource(id = R.string.apps_edit_apps_option_title) + WireCenterAlignedTopAppBar( + elevation = scrollState.rememberTopBarElevationState().value, + navigationIconType = NavigationIconType.Back(R.string.content_description_edit_apps_option_back_btn), + onNavigationPressed = onNavigateBack, + title = title + ) + } + ) { internalPadding -> + Column { + LazyColumn( + modifier = Modifier + .background(MaterialTheme.wireColorScheme.surface) + .padding(internalPadding) + .weight(1f) + .fillMaxSize() + ) { + item { + with(state) { + GroupOptionWithSwitch( + switchClickable = isUpdatingAppAccessAllowed, + switchVisible = true, + switchState = isAppAccessAllowed, + onClick = onChangeAppAccess, + isLoading = isLoadingAppsOption, + title = R.string.conversation_options_services_label, + subTitle = R.string.conversation_options_services_description + ) + } + } + if (!state.isUpdatingAppAccessAllowed) { + item { + UpgradeToGetAppsBanner() + } + } + } + } + } + + if (state.shouldShowDisableAppsConfirmationDialog) { + DisableServicesConfirmationDialog( + onConfirm = onDisableAppsConfirm, + onDialogDismiss = onDisableAppsDismiss + ) + } + + if (state.hasErrorOnUpdateAppAccess) { + val errorMessage = stringResource(R.string.label_general_error) + LaunchedEffect(true) { + coroutineScope.launch { + snackbarHostState.showSnackbar(errorMessage) + } + } + } +} + +@Composable +private fun DisableServicesConfirmationDialog(onConfirm: () -> Unit, onDialogDismiss: () -> Unit) { + DisableConfirmationDialog( + title = R.string.disable_services_dialog_title, + text = R.string.disable_services_dialog_text, + onDismiss = onDialogDismiss, + onConfirm = onConfirm + ) +} + +@PreviewMultipleThemes +@Composable +fun UpdateAppsAccessEnabledPreview() = WireTheme { + UpdateAppsAccessContent( + onNavigateBack = {}, + onChangeAppAccess = {}, + onDisableAppsConfirm = {}, + onDisableAppsDismiss = {}, + state = UpdateAppsAccessState( + isAppAccessAllowed = true, + isUpdatingAppAccessAllowed = true, + isLoadingAppsOption = false, + shouldShowDisableAppsConfirmationDialog = false + ), + ) +} + +@PreviewMultipleThemes +@Composable +fun UpdateAppsAccessDisabledPreview() = WireTheme { + UpdateAppsAccessContent( + onNavigateBack = {}, + onChangeAppAccess = {}, + onDisableAppsConfirm = {}, + onDisableAppsDismiss = {}, + state = UpdateAppsAccessState( + isAppAccessAllowed = true, + isUpdatingAppAccessAllowed = false, + isLoadingAppsOption = false, + shouldShowDisableAppsConfirmationDialog = false + ), + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessState.kt new file mode 100644 index 00000000000..3faa9dd0e66 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessState.kt @@ -0,0 +1,26 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.details.updateappsaccess + +data class UpdateAppsAccessState( + val isAppAccessAllowed: Boolean, + val isUpdatingAppAccessAllowed: Boolean, + val isLoadingAppsOption: Boolean, + val shouldShowDisableAppsConfirmationDialog: Boolean, + val hasErrorOnUpdateAppAccess: Boolean = false +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModel.kt new file mode 100644 index 00000000000..1e866cfdba0 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModel.kt @@ -0,0 +1,205 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.details.updateappsaccess + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase +import com.wire.android.ui.navArgs +import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.conversation.ConversationDetails +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.user.SelfUser +import com.wire.kalium.logic.data.user.type.isTeamAdmin +import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase +import com.wire.kalium.logic.feature.conversation.UpdateConversationAccessRoleUseCase +import com.wire.kalium.logic.feature.conversation.apps.ChangeAccessForAppsInConversationUseCase +import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppsAllowedForUsageUseCase +import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class UpdateAppsAccessViewModel @Inject constructor( + private val dispatcher: DispatcherProvider, + private val observeConversationDetails: ObserveConversationDetailsUseCase, + private val observeConversationMembers: ObserveParticipantsForConversationUseCase, + private val observeIsAppsAllowedForUsage: ObserveIsAppsAllowedForUsageUseCase, + private val selfUser: ObserveSelfUserUseCase, + private val changeAccessForAppsInConversation: ChangeAccessForAppsInConversationUseCase, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val updateAppsAccessNavArgs: UpdateAppsAccessNavArgs = savedStateHandle.navArgs() + private val conversationId: QualifiedID = updateAppsAccessNavArgs.conversationId + private val currentAccessParams = updateAppsAccessNavArgs.updateAppsAccessParams + + var updateAppsAccessState by mutableStateOf( + UpdateAppsAccessState( + isAppAccessAllowed = false, + isUpdatingAppAccessAllowed = false, + isLoadingAppsOption = false, + shouldShowDisableAppsConfirmationDialog = false + ) + ) + + init { + observeConversationDetails() + } + + @Suppress("DestructuringDeclarationWithTooManyEntries") + private fun observeConversationDetails() { + viewModelScope.launch { + val conversationDetailsFlow = observeConversationDetails(conversationId) + .filterIsInstance() + .map { it.conversationDetails } + .distinctUntilChanged() + .flowOn(dispatcher.io()) + .shareIn(this, SharingStarted.WhileSubscribed(), 1) + + val isSelfAdminFlow = observeConversationMembers(conversationId) + .map { it.isSelfAnAdmin } + .distinctUntilChanged() + + // TODO(refactor): Move all this logic to a UseCase in kalium if possible. + combine( + observeIsAppsAllowedForUsage(), + conversationDetailsFlow, + isSelfAdminFlow, + selfUser() + ) { isTeamAllowedToUseApps, conversationDetails, isSelfAnAdmin, selfUser -> + CombineFour(isTeamAllowedToUseApps, conversationDetails, isSelfAnAdmin, selfUser) + }.collect { (isTeamAllowedToUseApps, conversationDetails, isSelfAnAdmin, selfUser) -> + val isTeamAdmin = selfUser.userType.isTeamAdmin() + val isSelfInConversationTeam = selfUser.teamId == conversationDetails.conversation.teamId + val isSelfChannelTeamAdmin = + (conversationDetails is ConversationDetails.Group.Channel && isTeamAdmin && isSelfInConversationTeam) + val canSelfPerformAdminActions = isSelfAnAdmin || isSelfChannelTeamAdmin + + updateAppsAccessState = updateAppsAccessState.copy( + isAppAccessAllowed = conversationDetails.conversation.isServicesAllowed() && isTeamAllowedToUseApps, + isUpdatingAppAccessAllowed = canSelfPerformAdminActions && isTeamAllowedToUseApps, + isLoadingAppsOption = false, + shouldShowDisableAppsConfirmationDialog = false + ) + } + } + } + + private data class CombineFour( + val isAppsUsageAllowed: Boolean, + val conversationDetails: ConversationDetails, + val isSelfAnAdmin: Boolean, + val selfUser: SelfUser + ) + + fun onAppsAccessUpdate(shouldEnableAppsAccess: Boolean) { + updateState(updateAppsAccessState.copy(isLoadingAppsOption = true, isAppAccessAllowed = shouldEnableAppsAccess)) + when (shouldEnableAppsAccess) { + true -> updateAppsAccessRemotely(true) + false -> updateState(updateAppsAccessState.copy(shouldShowDisableAppsConfirmationDialog = true)) + } + } + + private fun updateAppsAccessRemotely(shouldEnableAppsAccess: Boolean) { + viewModelScope.launch { + val result = withContext(dispatcher.io()) { + updateConversationAccess( + enableGuestAndNonTeamMember = currentAccessParams.isGuestAllowed, + enableServices = shouldEnableAppsAccess, + conversationId = conversationId + ) + } + + when (result) { + is UpdateConversationAccessRoleUseCase.Result.Failure -> + updateState( + updateAppsAccessState.copy( + isAppAccessAllowed = !shouldEnableAppsAccess, + hasErrorOnUpdateAppAccess = true, + isLoadingAppsOption = false + ) + ) + + is UpdateConversationAccessRoleUseCase.Result.Success -> updateState( + updateAppsAccessState.copy( + isAppAccessAllowed = shouldEnableAppsAccess, + hasErrorOnUpdateAppAccess = false, + isLoadingAppsOption = false, + ) + ) + } + } + } + + private suspend fun updateConversationAccess( + enableGuestAndNonTeamMember: Boolean, + enableServices: Boolean, + conversationId: ConversationId + ): UpdateConversationAccessRoleUseCase.Result { + + val accessRoles = Conversation + .accessRolesFor( + guestAllowed = enableGuestAndNonTeamMember, + servicesAllowed = enableServices, + nonTeamMembersAllowed = enableGuestAndNonTeamMember + ) + + val access = Conversation.accessFor(guestsAllowed = enableGuestAndNonTeamMember) + + return changeAccessForAppsInConversation( + conversationId = conversationId, + accessRoles = accessRoles, + access = access + ) + } + + fun onServiceDialogConfirm() { + updateState(updateAppsAccessState.copy(shouldShowDisableAppsConfirmationDialog = false, isLoadingAppsOption = true)) + updateAppsAccessRemotely(false) + } + + fun onAppsDialogDismiss() { + updateState( + updateAppsAccessState.copy( + isLoadingAppsOption = false, + shouldShowDisableAppsConfirmationDialog = false, + isAppAccessAllowed = !updateAppsAccessState.isAppAccessAllowed + ) + ) + } + + private fun updateState(newState: UpdateAppsAccessState) { + updateAppsAccessState = newState + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt index 2a01bbd58ee..7624555602f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt @@ -17,6 +17,7 @@ */ package com.wire.android.ui.home.conversations.edit +import android.R.id.message import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -56,7 +57,7 @@ fun MessageOptionsModalSheetLayout( onReactionClick: (messageId: String, reactionEmoji: String) -> Unit, onDetailsClick: (messageId: String, isSelfMessage: Boolean) -> Unit, onReplyClick: (UIMessage.Regular) -> Unit, - onEditClick: (messageId: String, messageBody: String, mentions: List) -> Unit, + onEditClick: (messageId: String, messageBody: String, mentions: List, isMultipart: Boolean) -> Unit, onShareAssetClick: (messageId: String) -> Unit, onDownloadAssetClick: (messageId: String) -> Unit, onOpenAssetClick: (messageId: String) -> Unit, @@ -110,7 +111,7 @@ private fun MessageOptionsModalContent( onReactionClick: (messageId: String, reactionEmoji: String) -> Unit, onDetailsClick: (messageId: String, isSelfMessage: Boolean) -> Unit, onReplyClick: (UIMessage.Regular) -> Unit, - onEditClick: (messageId: String, messageBody: String, mentions: List) -> Unit, + onEditClick: (messageId: String, messageBody: String, mentions: List, isMultipart: Boolean) -> Unit, onShareAssetClick: (messageId: String) -> Unit, onDownloadAssetClick: (messageId: String) -> Unit, onOpenAssetClick: (messageId: String) -> Unit, @@ -129,7 +130,7 @@ private fun MessageOptionsModalContent( isComposite = message.messageContent is UIMessageContent.Composite, isEphemeral = isEphemeral, isEditable = !isUploading && !isDeleted && (message.messageContent?.isEditable() ?: false) && isMyMessage, - isCopyable = !isUploading && !isDeleted && !isEphemeral && message.messageContent is Copyable, + isCopyable = message.isCopyable(), isOpenable = true, onCopyClick = remember(message.messageContent) { (message.messageContent as? Copyable)?.textToCopy(context.resources)?.let { @@ -179,7 +180,8 @@ private fun MessageOptionsModalContent( onEditClick( message.header.messageId, message.messageContent.messageBody.message.asString(context.resources), - (message.messageContent.messageBody.message as? UIText.DynamicString)?.mentions ?: listOf() + (message.messageContent.messageBody.message as? UIText.DynamicString)?.mentions ?: listOf(), + false, ) } @@ -189,7 +191,8 @@ private fun MessageOptionsModalContent( onEditClick( message.header.messageId, this?.message?.asString(context.resources) ?: "", - (this?.message as? UIText.DynamicString)?.mentions ?: listOf() + (this?.message as? UIText.DynamicString)?.mentions ?: listOf(), + true, ) } } @@ -223,6 +226,24 @@ private fun MessageOptionsModalContent( ) } +private fun UIMessage.Regular.isCopyable() = + when { + isPending -> false + isDeleted -> false + header.messageStatus.expirationStatus is ExpirationStatus.Expirable -> false + messageContent !is Copyable -> false + messageContent.isEmptyMultipartText() -> false + else -> true + } + +private fun UIMessageContent.Regular?.isEmptyMultipartText() = + (this as? UIMessageContent.Multipart)?.messageBody?.message?.let { message -> + when (message) { + is UIText.DynamicString -> message.value.isEmpty() + else -> false + } + } ?: false + @PreviewMultipleThemes @Composable fun PreviewMessageOptionsModalSheetLayout() = WireTheme { @@ -234,7 +255,7 @@ fun PreviewMessageOptionsModalSheetLayout() = WireTheme { onReactionClick = { _, _ -> }, onDetailsClick = { _, _ -> }, onReplyClick = { }, - onEditClick = { _, _, _ -> }, + onEditClick = { _, _, _, _ -> }, onShareAssetClick = { }, onDownloadAssetClick = { }, onOpenAssetClick = { } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt index a15cca35875..098924dfeaf 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt @@ -101,7 +101,7 @@ fun ConversationMediaScreen( Content( state = state, onNavigationPressed = { navigator.navigateBack() }, - onImageFullScreenMode = { conversationId, messageId, isSelfAsset -> + onImageFullScreenMode = { conversationId, messageId, isSelfAsset, cellAssetId -> navigator.navigate( NavigationCommand( MediaGalleryScreenDestination( @@ -109,7 +109,8 @@ fun ConversationMediaScreen( messageId = messageId, isSelfAsset = isSelfAsset, isEphemeral = false, - messageOptionsEnabled = false + messageOptionsEnabled = false, + cellAssetId = cellAssetId, ) ) ) @@ -167,7 +168,8 @@ fun ConversationMediaScreen( private fun Content( state: ConversationAssetMessagesViewState, initialPage: ConversationMediaScreenTabItem = ConversationMediaScreenTabItem.PICTURES, - onImageFullScreenMode: (conversationId: ConversationId, messageId: String, isSelfAsset: Boolean) -> Unit = { _, _, _ -> }, + onImageFullScreenMode: + (conversationId: ConversationId, messageId: String, isSelfAsset: Boolean, cellAssetId: String?) -> Unit = { _, _, _, _ -> }, onAssetItemClicked: (String) -> Unit = {}, onOpenAssetOptions: (messageId: String, isMyMessage: Boolean) -> Unit = { _, _ -> }, onNavigationPressed: () -> Unit = {}, @@ -216,7 +218,9 @@ private fun Content( ConversationMediaScreenTabItem.PICTURES -> ImageAssetsContent( imageMessageList = state.imageMessages, assetStatuses = state.assetStatuses, - onImageClicked = onImageFullScreenMode, + onImageClicked = { conversationId, messageId, isSelfAsset -> + onImageFullScreenMode(conversationId, messageId, isSelfAsset, null) + }, onImageLongClicked = onOpenAssetOptions ) 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 40f77d751d4..a1474246a56 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 @@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.wire.android.R import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.home.conversations.details.participants.model.MemberSectionActions import com.wire.android.ui.home.conversations.details.participants.sectionWithElements import com.wire.android.ui.home.conversations.messagedetails.model.MessageDetailsReadReceiptsData import com.wire.android.ui.theme.WireTheme @@ -58,7 +59,8 @@ fun MessageDetailsReadReceipts( header = "", items = readReceiptsData.readReceipts, onRowItemClicked = { }, - showRightArrow = false + showRightArrow = false, + sectionActions = MemberSectionActions.NoActions ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMessage.kt index 974143cc861..1c7f405bebf 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMessage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMessage.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource @@ -56,12 +57,14 @@ import com.wire.android.R import com.wire.android.model.Clickable import com.wire.android.model.ImageAsset import com.wire.android.ui.common.StatusBox +import com.wire.android.ui.common.applyIf import com.wire.android.ui.common.clickable import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.typography import com.wire.android.ui.home.conversations.messages.item.MessageStyle import com.wire.android.ui.home.conversations.messages.item.highlighted +import com.wire.android.ui.home.conversations.messages.item.isBubble import com.wire.android.ui.home.conversations.model.UIQuotedMessage import com.wire.android.ui.markdown.MarkdownInline import com.wire.android.ui.markdown.MessageColors @@ -79,7 +82,8 @@ private const val TEXT_QUOTE_MAX_LINES = 7 data class QuotedMessageStyle( val quotedStyle: QuotedStyle, val messageStyle: MessageStyle, - val selfAccent: Accent + val selfAccent: Accent, + val senderAccent: Accent ) /** @@ -200,7 +204,7 @@ fun QuotedMessagePreview( modifier = modifier, messageData = quotedMessageData, clickable = null, - style = QuotedMessageStyle(QuotedStyle.PREVIEW, MessageStyle.NORMAL, Accent.Unknown) + style = QuotedMessageStyle(QuotedStyle.PREVIEW, MessageStyle.NORMAL, Accent.Unknown, quotedMessageData.senderAccent) ) { Box( modifier = Modifier @@ -251,11 +255,13 @@ internal fun QuotedMessageContent( color = background, shape = quoteOutlineShape ) - .border( - width = 1.dp, - color = MaterialTheme.wireColorScheme.outline, - shape = quoteOutlineShape - ) + .applyIf(!style.messageStyle.isBubble()) { + border( + width = 1.dp, + color = MaterialTheme.wireColorScheme.outline, + shape = quoteOutlineShape + ) + } .padding(dimensions().spacing4x) .fillMaxWidth() .height(IntrinsicSize.Min) @@ -280,7 +286,7 @@ internal fun QuotedMessageContent( QuotedMessageTopRow( senderName, displayReplyArrow = style.quotedStyle == QuotedStyle.COMPLETE, - messageStyle = style.messageStyle + quotedMessageStyle = style ) Row(horizontalArrangement = Arrangement.spacedBy(dimensions().spacing4x)) { Column( @@ -313,12 +319,16 @@ internal fun QuotedMessageContent( private fun QuotedMessageTopRow( senderName: String?, displayReplyArrow: Boolean, - messageStyle: MessageStyle + quotedMessageStyle: QuotedMessageStyle ) { + val messageStyle = quotedMessageStyle.messageStyle + + val accentScheme = colorsScheme(quotedMessageStyle.senderAccent) + val authorColor = when (messageStyle) { - MessageStyle.BUBBLE_SELF -> colorsScheme().selfBubble.primaryOnSecondary - MessageStyle.BUBBLE_OTHER -> colorsScheme().otherBubble.primaryOnSecondary + MessageStyle.BUBBLE_SELF -> accentScheme.selfBubble.primaryOnSecondary + MessageStyle.BUBBLE_OTHER -> accentScheme.primary MessageStyle.NORMAL -> colorsScheme().onSurfaceVariant } @@ -515,7 +525,7 @@ private fun QuotedImage( QuotedMessageTopRow( senderName = senderName.asString(), displayReplyArrow = true, - messageStyle = style.messageStyle + quotedMessageStyle = style ) MainContentText(stringResource(R.string.notification_shared_picture)) QuotedMessageOriginalDate(originalDateTimeText, style) @@ -655,13 +665,13 @@ internal fun MainMarkdownText(text: String, messageStyle: MessageStyle, accent: } @Composable -internal fun MainContentText(text: String, fontStyle: FontStyle = FontStyle.Normal) { +internal fun MainContentText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = colorsScheme().onSurfaceVariant) { Text( text = text, style = typography().subline01, maxLines = TEXT_QUOTE_MAX_LINES, overflow = TextOverflow.Ellipsis, - color = colorsScheme().onSurfaceVariant, + color = color, fontStyle = fontStyle ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMultipartMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMultipartMessage.kt index 3d86e19b10a..945ea6ded43 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMultipartMessage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMultipartMessage.kt @@ -54,8 +54,8 @@ import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.domain.model.AttachmentFileType.VIDEO import com.wire.android.feature.cells.domain.model.icon import com.wire.android.model.Clickable -import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions +import com.wire.android.ui.home.conversations.messages.item.textColor import com.wire.android.ui.home.conversations.model.UIMultipartQuotedContent import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.wireColorScheme @@ -141,24 +141,24 @@ private fun MediaAttachmentThumbnail(attachment: UIMultipartQuotedContent) { } @Composable -private fun MultipleAttachmentsLabel(count: Int) { +private fun MultipleAttachmentsLabel(style: QuotedMessageStyle, count: Int) { Row( horizontalArrangement = Arrangement.spacedBy(dimensions().spacing4x) ) { Icon( painter = painterResource(R.drawable.ic_multiple_files), contentDescription = null, - tint = colorsScheme().secondaryText + tint = style.messageStyle.textColor(), ) Text( text = pluralStringResource(R.plurals.reply_multiple_files, count, count), - color = colorsScheme().secondaryText + color = style.messageStyle.textColor(), ) } } @Composable -private fun FileIconAndNameRow(file: UIMultipartQuotedContent) { +private fun FileIconAndNameRow(style: QuotedMessageStyle, file: UIMultipartQuotedContent) { val name = file.name val attachmentFileType = name.fileExtension()?.let { AttachmentFileType.fromExtension(it) } ?: AttachmentFileType.OTHER @@ -177,7 +177,7 @@ private fun FileIconAndNameRow(file: UIMultipartQuotedContent) { ) Text( text = if (file.assetAvailable) name else stringResource(R.string.asset_message_failed_download_text), - color = colorsScheme().secondaryText + color = style.messageStyle.textColor(), ) } } @@ -217,19 +217,25 @@ fun QuotedMultipartMessageContent( } else { quotedMultipartMessage.mediaAttachment?.let { mediaAttachment -> if (mediaAttachment.assetAvailable) { - MainContentText(mediaAttachment.name) + MainContentText( + text = mediaAttachment.name, + color = style.messageStyle.textColor() + ) } else { - MainContentText(stringResource(R.string.asset_message_failed_download_text)) + MainContentText( + text = stringResource(R.string.asset_message_failed_download_text), + color = style.messageStyle.textColor() + ) } } } if (quotedMultipartMessage.attachmentsCount > 1) { - MultipleAttachmentsLabel(quotedMultipartMessage.attachmentsCount) + MultipleAttachmentsLabel(style, quotedMultipartMessage.attachmentsCount) } quotedMultipartMessage.fileAttachment?.let { fileAttachment -> - FileIconAndNameRow(fileAttachment) + FileIconAndNameRow(style, fileAttachment) } } }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt index 93f7011e93e..63945de6cea 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt @@ -83,6 +83,7 @@ class MessageDraftViewModel @Inject constructor( draftText = draft.text, selectedMentions = draft.selectedMentionList.mapNotNull { it.toUiMention(draft.text) }, editMessageId = draft.editMessageId, + isMultipart = draft.isMultipartEdit, quotedMessage = quotedMessage as? UIQuotedMessage.UIQuotedData, quotedMessageId = (quotedMessage as? UIQuotedMessage.UIQuotedData)?.messageId, ) @@ -100,9 +101,7 @@ class MessageDraftViewModel @Inject constructor( } } - fun onMessageTextUpdate(newText: String) { - if (state.value.draftText != newText) { - saveDraft(state.value.toDraft(newText)) - } + fun onMessageTextUpdate(messageDraft: MessageDraft) { + saveDraft(messageDraft) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageAuthorRow.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageAuthorRow.kt index de67c6f4483..2d62c5aabc6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageAuthorRow.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageAuthorRow.kt @@ -30,6 +30,7 @@ import com.wire.android.ui.common.LegalHoldIndicator import com.wire.android.ui.common.UserBadge import com.wire.android.ui.common.applyIf import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.typography import com.wire.android.ui.home.conversations.model.MessageHeader import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.wireColorScheme @@ -106,7 +107,7 @@ fun MessageSmallLabel( ) { Text( text = text, - style = MaterialTheme.typography.labelSmall.copy(color = messageStyle.textColor()), + style = typography().subline01.copy(color = messageStyle.textColor()), maxLines = 1, modifier = modifier.alpha(messageStyle.alpha()) ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageBubbleItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageBubbleItem.kt index 51c814abb57..074448d7ea4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageBubbleItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageBubbleItem.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.unit.dp import com.wire.android.ui.common.applyIf import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions -import com.wire.android.ui.common.spacers.HorizontalSpace import com.wire.android.ui.home.conversations.model.MessageSource import com.wire.android.ui.home.conversations.model.MessageStatus import com.wire.android.ui.home.conversations.model.UIMessage @@ -90,7 +89,7 @@ fun MessageBubbleItem( val leadingPadding = if (leading != null) { dimensions().spacing48x } else { - dimensions().spacing0x + dimensions().spacing12x } Row( modifier = Modifier.fillMaxWidth(), @@ -101,12 +100,10 @@ fun MessageBubbleItem( }, verticalAlignment = Alignment.Bottom ) { - if (leading != null) { - Box(Modifier.width(leadingPadding), contentAlignment = Alignment.BottomStart) { + Box(Modifier.width(leadingPadding), contentAlignment = Alignment.BottomStart) { + if (leading != null) { leading() } - } else { - HorizontalSpace.x12() } Column( modifier = Modifier.applyIf(!message.decryptionFailed) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt index b22f27cfc1a..8d749621874 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt @@ -27,7 +27,7 @@ sealed class MessageClickActions { open val onProfileClicked: (String) -> Unit = {} open val onReactionClicked: (String, String) -> Unit = { _, _ -> } open val onAssetClicked: (String) -> Unit = {} - open val onImageClicked: (UIMessage.Regular, Boolean) -> Unit = { _, _ -> } + open val onImageClicked: (UIMessage.Regular, Boolean, String?) -> Unit = { _, _, _ -> } open val onLinkClicked: (String) -> Unit = {} open val onReplyClicked: (UIMessage.Regular) -> Unit = {} open val onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit = { _, _ -> } @@ -44,7 +44,7 @@ sealed class MessageClickActions { override val onProfileClicked: (String) -> Unit = {}, override val onReactionClicked: (String, String) -> Unit = { _, _ -> }, override val onAssetClicked: (String) -> Unit = {}, - override val onImageClicked: (UIMessage.Regular, Boolean) -> Unit = { _, _ -> }, + override val onImageClicked: (UIMessage.Regular, Boolean, String?) -> Unit = { _, _, _ -> }, override val onLinkClicked: (String) -> Unit = {}, override val onReplyClicked: (UIMessage.Regular) -> Unit = {}, override val onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit = { _, _ -> }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index aea92f422ca..49bc4429ca1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -48,7 +48,7 @@ internal fun UIMessage.Regular.MessageContentAndStatus( searchQuery: String, messageStyle: MessageStyle, onAssetClicked: (String) -> Unit, - onImageClicked: (UIMessage.Regular, Boolean) -> Unit, + onImageClicked: (UIMessage.Regular, Boolean, String?) -> Unit, onProfileClicked: (String) -> Unit, onLinkClicked: (String) -> Unit, onReplyClicked: (UIMessage.Regular) -> Unit, @@ -61,21 +61,26 @@ internal fun UIMessage.Regular.MessageContentAndStatus( onAssetClicked(header.messageId) }) } + val onImageClickable = remember(message) { Clickable(enabled = isAvailable, onClick = { - onImageClicked(message, source == MessageSource.Self) + onImageClicked(message, source == MessageSource.Self, null) }) } + + val onMultipartImageClickable: (String) -> Unit = remember(message) { + { + onImageClicked(message, source == MessageSource.Self, it) + } + } + val onReplyClickable = remember(message) { Clickable { onReplyClicked(message) } } Row { - Column( - Modifier - .applyIf(!messageStyle.isBubble()) { weight(1F) } - ) { + Column(Modifier.applyIf(!messageStyle.isBubble()) { weight(1F) }) { MessageContent( message = message, messageContent = messageContent, @@ -83,6 +88,7 @@ internal fun UIMessage.Regular.MessageContentAndStatus( assetStatus = assetStatus, onAssetClick = onAssetClickable, onImageClick = onImageClickable, + onMultipartImageClick = onMultipartImageClickable, onOpenProfile = onProfileClicked, onLinkClick = onLinkClicked, onReplyClick = onReplyClickable, @@ -129,6 +135,7 @@ private fun MessageContent( assetStatus: AssetTransferStatus?, onAssetClick: Clickable, onImageClick: Clickable, + onMultipartImageClick: (String) -> Unit, onOpenProfile: (String) -> Unit, onLinkClick: (String) -> Unit, onReplyClick: Clickable, @@ -171,7 +178,8 @@ private fun MessageContent( style = QuotedMessageStyle( quotedStyle = QuotedStyle.COMPLETE, messageStyle = messageStyle, - selfAccent = accent + selfAccent = accent, + senderAccent = it.senderAccent ), clickable = onReplyClick ) @@ -180,7 +188,8 @@ private fun MessageContent( style = QuotedMessageStyle( quotedStyle = QuotedStyle.COMPLETE, messageStyle = messageStyle, - selfAccent = accent + selfAccent = accent, + senderAccent = Accent.Unknown ) ) } @@ -211,7 +220,8 @@ private fun MessageContent( style = QuotedMessageStyle( quotedStyle = QuotedStyle.COMPLETE, messageStyle = messageStyle, - selfAccent = accent + selfAccent = accent, + senderAccent = it.senderAccent ), clickable = onReplyClick ) @@ -220,7 +230,8 @@ private fun MessageContent( style = QuotedMessageStyle( quotedStyle = QuotedStyle.COMPLETE, messageStyle = messageStyle, - selfAccent = accent + selfAccent = accent, + senderAccent = Accent.Unknown ) ) } @@ -323,7 +334,8 @@ private fun MessageContent( style = QuotedMessageStyle( quotedStyle = QuotedStyle.COMPLETE, messageStyle = messageStyle, - selfAccent = accent + selfAccent = accent, + senderAccent = it.senderAccent ), clickable = onReplyClick ) @@ -332,7 +344,8 @@ private fun MessageContent( style = QuotedMessageStyle( quotedStyle = QuotedStyle.COMPLETE, messageStyle = messageStyle, - selfAccent = accent + selfAccent = accent, + senderAccent = Accent.Unknown ) ) } @@ -357,6 +370,7 @@ private fun MessageContent( attachments = messageContent.attachments, messageStyle = messageStyle, accent = accent, + onImageAttachmentClick = onMultipartImageClick ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentItem.kt index 6e843ff88a6..645dfbc4d61 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentItem.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -41,7 +40,6 @@ import com.wire.android.ui.home.conversations.model.MessageSource import com.wire.android.ui.home.conversations.model.MessageStatus import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.theme.Accent -import com.wire.android.ui.theme.wireColorScheme import com.wire.kalium.logic.data.asset.AssetTransferStatus @Suppress("CyclomaticComplexMethod") @@ -162,19 +160,19 @@ fun MessageContentItem( if (header.messageStatus.editStatus is MessageEditStatus.Edited) { HorizontalSpace.x8() MessageSmallLabel( - text = "• " + stringResource(R.string.label_message_status_edited), + text = "•", messageStyle = messageStyle ) HorizontalSpace.x8() + MessageSmallLabel( + text = stringResource(R.string.label_message_status_edited), + messageStyle = messageStyle + ) } MessageBubbleExpireFooter( messageStyle = messageStyle, - selfDeletionTimerState = selfDeletionTimerState, - accentColor = MaterialTheme.wireColorScheme.wireAccentColors.getOrDefault( - header.accent, - MaterialTheme.wireColorScheme.primary - ) + selfDeletionTimerState = selfDeletionTimerState ) if (isMyMessage && shouldDisplayMessageStatus) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageExpirationItems.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageExpirationItems.kt index 5204fbf760b..b4d2e501ded 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageExpirationItems.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageExpirationItems.kt @@ -94,7 +94,6 @@ fun MessageBubbleEphemeralItem( @Composable fun MessageBubbleExpireFooter( messageStyle: MessageStyle, - accentColor: Color, selfDeletionTimerState: SelfDeletionTimerHelper.SelfDeletionTimerState, modifier: Modifier = Modifier, ) { @@ -108,7 +107,6 @@ fun MessageBubbleExpireFooter( SelfDeletionTimerIcon( selfDeletionTimerState, messageStyle, - accentColor, modifier = Modifier.alpha(messageStyle.alpha()) ) HorizontalSpace.x4() @@ -127,13 +125,12 @@ fun MessageBubbleExpireFooter( private fun SelfDeletionTimerIcon( state: SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable, messageStyle: MessageStyle, - accentColor: Color, modifier: Modifier = Modifier, discreteSteps: Int? = 8 ) { val emptyColor = when (messageStyle) { - MessageStyle.BUBBLE_SELF -> accentColor + MessageStyle.BUBBLE_SELF -> colorsScheme().selfBubble.primary MessageStyle.BUBBLE_OTHER -> colorsScheme().background MessageStyle.NORMAL -> colorsScheme().background } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageReactionsItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageReactionsItem.kt index de458637468..3b0a9298e90 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageReactionsItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageReactionsItem.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import com.wire.android.ui.common.dimensions import com.wire.android.ui.home.conversations.messages.ReactionPill import com.wire.android.ui.home.conversations.model.MessageFooter +import com.wire.android.ui.home.conversations.model.Reaction import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes @@ -46,24 +47,22 @@ fun MessageReactionsItem( onLongClick: (() -> Unit)? = null, ) { // to eliminate adding unnecessary paddings when the list is empty - if (messageFooter.reactions.entries.isNotEmpty()) { + if (messageFooter.reactionMap.isNotEmpty()) { FlowRow( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(dimensions().spacing4x, itemsAlignment), verticalArrangement = Arrangement.spacedBy(dimensions().spacing6x, Alignment.Top), maxItemsInEachRow = if (messageStyle.isBubble()) BUBBLE_MAX_REACTIONS_IN_ROW else Int.MAX_VALUE, ) { - messageFooter.reactions.entries - .sortedBy { it.key } - .forEach { - val reaction = it.key - val count = it.value + messageFooter.reactionMap + .toSortedMap() + .forEach { (emoji, reaction) -> ReactionPill( - emoji = reaction, - count = count, - isOwn = messageFooter.ownReactions.contains(reaction), + emoji = emoji, + count = reaction.count, + isOwn = reaction.isSelf, onTap = { - onReactionClicked(messageFooter.messageId, reaction) + onReactionClicked(messageFooter.messageId, emoji) }, onLongClick = onLongClick ) @@ -80,16 +79,15 @@ fun LongMessageReactionsItemPreview() = WireTheme(accent = Accent.Green) { messageStyle = MessageStyle.NORMAL, messageFooter = MessageFooter( messageId = "messageId", - reactions = mapOf( - "👍" to 1, - "👎" to 2, - "👏" to 3, - "🤔" to 4, - "🤷" to 5, - "🤦" to 6, - "🤢" to 7 + reactionMap = mapOf( + "👍" to Reaction(1, isSelf = false), + "👎" to Reaction(2, isSelf = false), + "👏" to Reaction(3, isSelf = false), + "🤔" to Reaction(4, isSelf = false), + "🤷" to Reaction(5, isSelf = false), + "🤦" to Reaction(6, isSelf = false), + "🤢" to Reaction(7, isSelf = true), ), - ownReactions = setOf("👍"), ), onReactionClicked = { _, _ -> } ) @@ -104,16 +102,15 @@ fun LongMessageReactionsBubbleItemPreview() = WireTheme(accent = Accent.Petrol) messageStyle = MessageStyle.BUBBLE_OTHER, messageFooter = MessageFooter( messageId = "messageId", - reactions = mapOf( - "👍" to 1, - "👎" to 2, - "👏" to 3, - "🤔" to 4, - "🤷" to 5, - "🤦" to 6, - "🤢" to 7 + reactionMap = mapOf( + "👍" to Reaction(1, isSelf = false), + "👎" to Reaction(2, isSelf = false), + "👏" to Reaction(3, isSelf = false), + "🤔" to Reaction(4, isSelf = false), + "🤷" to Reaction(5, isSelf = false), + "🤦" to Reaction(6, isSelf = false), + "🤢" to Reaction(7, isSelf = true), ), - ownReactions = setOf("👍"), ), onReactionClicked = { _, _ -> } ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageStatusIndicator.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageStatusIndicator.kt index 39578be81ce..0d4d08001df 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageStatusIndicator.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageStatusIndicator.kt @@ -29,10 +29,10 @@ import androidx.compose.ui.res.stringResource import com.wire.android.R import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.spacers.HorizontalSpace +import com.wire.android.ui.common.typography import com.wire.android.ui.home.conversations.model.MessageFlowStatus 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.ui.PreviewMultipleThemes @Composable @@ -92,7 +92,7 @@ fun MessageStatusIndicator( HorizontalSpace.x2() Text( text = status.count.toString(), - style = MaterialTheme.wireTypography.label03.copy(color = defaultTint) + style = typography().subline01.copy(color = defaultTint) ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageStyle.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageStyle.kt index 1d8521bafc0..8b851ff4925 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageStyle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageStyle.kt @@ -34,8 +34,8 @@ enum class MessageStyle { fun MessageStyle.isBubble(): Boolean = this != MessageStyle.NORMAL fun MessageStyle.alpha() = when (this) { - MessageStyle.BUBBLE_SELF -> SELF_BUBBLE_OPACITY - MessageStyle.BUBBLE_OTHER -> 1F + MessageStyle.BUBBLE_SELF -> BUBBLE_OPACITY + MessageStyle.BUBBLE_OTHER -> BUBBLE_OPACITY MessageStyle.NORMAL -> 1F } @@ -112,4 +112,4 @@ fun MessageStyle.textAlign(): TextAlign { } } -private const val SELF_BUBBLE_OPACITY = 0.5F +private const val BUBBLE_OPACITY = 0.5F 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 190dfbd8203..3c8c5d89493 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 @@ -545,6 +545,38 @@ private fun SystemMessage.buildContent() = when (this) { id = R.string.label_system_message_cell_self_delete_disabled_for_conversation, ).toMarkdownAnnotatedString() } + + is SystemMessage.ConversationAppsEnabledChanged -> buildContent( + iconResId = R.drawable.ic_app, + iconTintColor = MaterialTheme.wireColorScheme.onBackground, + ) { + val markdownTextStyle = DefaultMarkdownTextStyle.copy(normalColor = MaterialTheme.wireColorScheme.primary) + val contentResId = when { + isAccessEnabled -> { + if (isAuthorSelfUser) { + R.string.label_system_message_apps_access_enabled_by_self + } else { + R.string.label_system_message_apps_access_enabled_by_other + } + } + else -> { + if (isAuthorSelfUser) { + R.string.label_system_message_apps_access_disabled_by_self + } else { + R.string.label_system_message_apps_access_disabled_by_other + } + } + } + val content = stringResource(id = contentResId, formatArgs = arrayOf(author.asString().markdownBold())) + val footer = stringResource(R.string.label_system_message_apps_access_enabled_disclaimer) + buildAnnotatedString { + append(content.toMarkdownAnnotatedString()) + if (isAccessEnabled) { + appendLine() + append(footer.toMarkdownAnnotatedString(markdownTextStyle)) + } + } + } } private fun AnnotatedString.Builder.appendVerticalSpace() = withStyle(ParagraphStyle()) { append(" ") } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewMessageTypes.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewMessageTypes.kt index 74fd766c1fe..76bd886709a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewMessageTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewMessageTypes.kt @@ -162,7 +162,7 @@ fun PreviewFailedSendMessage() { expirationStatus = ExpirationStatus.NotExpirable ) ), - messageFooter = mockFooter.copy(reactions = emptyMap(), ownReactions = emptySet()) + messageFooter = mockFooter.copy(reactionMap = emptyMap()) ) }, conversationDetailsData = ConversationDetailsData.None(null), @@ -184,7 +184,7 @@ fun PreviewFailedDecryptionMessage() { expirationStatus = ExpirationStatus.NotExpirable ) ), - messageFooter = mockFooter.copy(reactions = emptyMap(), ownReactions = emptySet()) + messageFooter = mockFooter.copy(reactionMap = emptyMap()) ) }, conversationDetailsData = ConversationDetailsData.None(null), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewMultipartReply.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewMultipartReply.kt index 84e0748463f..8c2300a1109 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewMultipartReply.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewMultipartReply.kt @@ -42,6 +42,7 @@ private fun PreviewMultipartMessage(text: String?, message: UIQuotedMultipartMes messageStyle = MessageStyle.NORMAL, quotedStyle = QuotedStyle.PREVIEW, selfAccent = Accent.Blue, + senderAccent = Accent.Amber ), accent = Accent.Blue, quotedMultipartMessage = message, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewSystemMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewSystemMessageItem.kt index 84ed8ab0da4..389ac48d5cd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewSystemMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewSystemMessageItem.kt @@ -470,3 +470,19 @@ fun PreviewSystemMessageConversationMessageCreatedUnverifiedWarning() { ) } } + +@PreviewMultipleThemes +@Composable +fun PreviewSystemMessageConversationMessageAppsAccessEnabled() { + WireTheme { + SystemMessageItem( + message = mockMessageWithKnock.copy( + messageContent = UIMessageContent.SystemMessage.ConversationAppsEnabledChanged( + author = UIText.DynamicString("Barbara"), + isAuthorSelfUser = true, + isAccessEnabled = true + ) + ) + ) + } +} 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 9afce899d36..61805705330 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 @@ -31,6 +31,7 @@ import com.wire.android.ui.home.conversations.model.MessageHeader import com.wire.android.ui.home.conversations.model.MessageSource import com.wire.android.ui.home.conversations.model.MessageStatus import com.wire.android.ui.home.conversations.model.MessageTime +import com.wire.android.ui.home.conversations.model.Reaction import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.home.conversations.model.UIMessageContent import com.wire.android.ui.home.conversations.model.messagetypes.asset.UIAssetMessage @@ -47,22 +48,27 @@ import kotlinx.datetime.Instant import okio.Path.Companion.toPath private const val MOCK_TIME_IN_SECONDS: Long = 1729837498 -val mockFooter = MessageFooter("", mapOf("👍" to 1), setOf("👍")) +val mockFooter = MessageFooter( + "", + mapOf( + "👍" to Reaction(1, isSelf = false), + "👍" to Reaction(count = 2, isSelf = true) + ) +) val mockFooterWithMultipleReactions = MessageFooter( messageId = "messageId", - reactions = mapOf( - "👍" to 1, - "👎" to 2, - "👏" to 3, - "🤔" to 4, - "🤷" to 5, - "🤦" to 6, - "🤢" to 7 + reactionMap = mapOf( + "👍" to Reaction(1, isSelf = true), + "👎" to Reaction(2, isSelf = false), + "👏" to Reaction(3, isSelf = false), + "🤔" to Reaction(4, isSelf = false), + "🤷" to Reaction(5, isSelf = false), + "🤦" to Reaction(6, isSelf = false), + "🤢" to Reaction(7, isSelf = false), ), - ownReactions = setOf("👍"), ) -val mockEmptyFooter = MessageFooter("", emptyMap(), emptySet()) +val mockEmptyFooter = MessageFooter("", emptyMap()) val mockMessageTime = MessageTime(Instant.fromEpochSeconds(MOCK_TIME_IN_SECONDS)) val mockHeader = MessageHeader( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/AssetBundle.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/AssetBundle.kt index 8c0b78f718d..bda8453cc6b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/AssetBundle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/AssetBundle.kt @@ -41,7 +41,8 @@ data class AssetBundle( val dataPath: Path, val dataSize: Long, val fileName: String, - val assetType: AttachmentType + val assetType: AttachmentType, + val audioWavesMask: List? = null, ) : Parcelable { @Stable @@ -68,7 +69,8 @@ data class AssetBundle( data class UriAsset( val uri: Uri, val saveToDeviceIfInvalid: Boolean = false, - val mimeType: String? = null + val mimeType: String? = null, + val audioWavesMask: List? = null, ) object PathParceler : Parceler { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt index 8c4beea743f..c69878c51e6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt @@ -45,6 +45,8 @@ import com.wire.android.model.ImageAsset import com.wire.android.ui.common.applyIf import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WireSecondaryButton +import com.wire.android.ui.common.button.secondaryButtonColors +import com.wire.android.ui.common.button.wireSecondaryButtonColors import com.wire.android.ui.common.clickable import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions @@ -152,6 +154,7 @@ internal fun MessageBody( MessageButtonsContent( messageId = messageId, buttonList = it, + messageStyle = messageStyle ) } } @@ -160,6 +163,7 @@ internal fun MessageBody( fun MessageButtonsContent( messageId: String, buttonList: List, + messageStyle: MessageStyle, modifier: Modifier = Modifier, viewModel: CompositeMessageViewModel = hiltViewModelScoped( @@ -194,7 +198,12 @@ fun MessageButtonsContent( loading = isPending, text = button.text, onClick = onCLick, - state = state + state = state, + colors = if (messageStyle.isBubble()) { + colorsScheme().otherBubble.secondaryButtonColors() + } else { + wireSecondaryButtonColors() + } ) if (index != buttonList.lastIndex) { Spacer(modifier = Modifier.padding(top = dimensions().spacing8x)) 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 28c25697855..0ce011faf1b 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 @@ -167,8 +167,23 @@ data class MessageHeader( @Serializable data class MessageFooter( val messageId: String, - val reactions: Map = emptyMap(), - val ownReactions: Set = emptySet() + val reactionMap: Map = emptyMap() +) { + // Backward-compatible properties for gradual migration + @Deprecated("Use reactionMap instead", ReplaceWith("reactionMap.mapValues { it.value.count }")) + val reactions: Map + get() = reactionMap.mapValues { it.value.count } + + @Deprecated("Use reactionMap instead", ReplaceWith("reactionMap.filter { it.value.isSelf }.keys")) + val ownReactions: Set + get() = reactionMap.filter { it.value.isSelf }.keys +} + +@Stable +@Serializable +data class Reaction( + val count: Int, + val isSelf: Boolean ) @Serializable @@ -591,6 +606,13 @@ sealed interface UIMessageContent { @Serializable data object NewConversationWithCellSelfDeleteDisabled : SystemMessage + + @Serializable + data class ConversationAppsEnabledChanged( + val author: UIText, + val isAuthorSelfUser: Boolean = false, + val isAccessEnabled: Boolean + ) : SystemMessage } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt index bf8fe214943..ad8bb88cebb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt @@ -19,11 +19,13 @@ package com.wire.android.ui.home.conversations.model.messagetypes.audio +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -41,6 +43,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SliderState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -50,6 +53,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.DpSize @@ -62,6 +70,8 @@ import com.wire.android.media.audiomessage.AudioMessageViewModel import com.wire.android.media.audiomessage.AudioMessageViewModelImpl import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState +import com.wire.android.media.audiomessage.equalizedWavesMask +import com.wire.android.media.audiomessage.sampledWavesMask import com.wire.android.model.Clickable import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties @@ -80,6 +90,7 @@ import com.wire.android.ui.common.spacers.HorizontalSpace import com.wire.android.ui.home.conversations.messages.item.MessageStyle import com.wire.android.ui.home.conversations.messages.item.highlighted import com.wire.android.ui.home.conversations.messages.item.isBubble +import com.wire.android.ui.home.conversations.messages.item.surface import com.wire.android.ui.home.conversations.messages.item.textColor import com.wire.android.ui.home.conversations.model.messagetypes.asset.UploadInProgressAssetMessage import com.wire.android.ui.theme.WireTheme @@ -121,7 +132,7 @@ private fun AudioMessageLayout( .applyIf(!messageStyle.isBubble()) { padding(top = dimensions().spacing4x) .background( - color = MaterialTheme.wireColorScheme.onPrimary, + color = messageStyle.surface(), shape = RoundedCornerShape(dimensions().messageAssetBorderRadius) ) .border( @@ -386,38 +397,21 @@ private fun AudioMessageSlider( waveMask: List?, onSliderPositionChange: (Float) -> Unit, ) { - Box(modifier = Modifier.fillMaxWidth()) { + val density = LocalDensity.current + val waveWidth = dimensions().spacing2x + val spaceBetweenWaves = dimensions().spacing1x + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val totalMs = if (totalTimeInMs is AudioState.TotalTimeInMs.Known) totalTimeInMs.value.toFloat() else 0f - val waves = waveMask?.ifEmpty { getDefaultWaveMask() } ?: getDefaultWaveMask() - val wavesAmount = waves.size - - val activatedColor = messageStyle.highlighted() - - val disabledColor = when (messageStyle) { - MessageStyle.BUBBLE_SELF -> colorsScheme().selfBubble.onPrimary.copy(alpha = 0.7F) - MessageStyle.BUBBLE_OTHER -> colorsScheme().otherBubble.onPrimary.copy(alpha = 0.7F) - MessageStyle.NORMAL -> colorsScheme().onTertiaryButtonDisabled - } - - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center), - verticalAlignment = Alignment.CenterVertically - ) { - waves.forEachIndexed { index, wave -> - val isWaveActivated = totalMs > 0 && (index / wavesAmount.toFloat()) < audioDuration.currentPositionInMs / totalMs - Spacer( - Modifier - .background( - color = if (isWaveActivated) activatedColor else disabledColor, - shape = RoundedCornerShape(dimensions().corner2x) - ) - .weight(2f) - .height(wave.dp) - ) - - Spacer(Modifier.weight(1F)) + val waves by remember(waveMask, constraints.maxWidth) { + derivedStateOf { + val wavesAmount = with(density) { + (constraints.maxWidth.toDp() / (waveWidth + spaceBetweenWaves)).toInt() + } + when { + wavesAmount <= 0 -> emptyList() + waveMask.isNullOrEmpty() -> getDefaultWaveMask(wavesAmount) + else -> waveMask.equalizedWavesMask().sampledWavesMask(wavesAmount) + } } } @@ -428,13 +422,12 @@ private fun AudioMessageSlider( thumb = { SliderDefaults.Thumb( interactionSource = remember { MutableInteractionSource() }, - colors = SliderDefaults.colors(thumbColor = activatedColor), - thumbSize = DpSize(dimensions().spacing4x, dimensions().spacing32x) + colors = SliderDefaults.colors(thumbColor = messageStyle.highlighted()), + thumbSize = DpSize(dimensions().spacing4x, dimensions().spacing32x), ) }, - track = { _ -> - // just empty, track is displayed by waves above - Spacer(Modifier.fillMaxWidth()) + track = { sliderState: SliderState -> + AudioMessageSliderTrack(sliderState, waves, messageStyle) }, colors = SliderDefaults.colors( inactiveTrackColor = colorsScheme().secondaryButtonDisabledOutline @@ -444,6 +437,43 @@ private fun AudioMessageSlider( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AudioMessageSliderTrack(state: SliderState, waves: List, messageStyle: MessageStyle) { + val progressPercentage = (state.value - state.valueRange.start) / (state.valueRange.endInclusive - state.valueRange.start) + val activatedColor = messageStyle.highlighted() + val disabledColor = when (messageStyle) { + MessageStyle.BUBBLE_SELF -> colorsScheme().selfBubble.onPrimary.copy(alpha = 0.7F) + MessageStyle.BUBBLE_OTHER -> colorsScheme().otherBubble.onPrimary.copy(alpha = 0.7F) + MessageStyle.NORMAL -> colorsScheme().onTertiaryButtonDisabled + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + drawContent() + drawRect( + color = activatedColor, + blendMode = BlendMode.SrcAtop, + size = size.copy(width = size.width * progressPercentage, height = size.height) + ) + } + ) { + waves.forEachIndexed { index, wave -> + Spacer( + Modifier + .background(color = disabledColor, shape = RoundedCornerShape(dimensions().corner2x)) + .weight(2f) + .animateContentSize() + .height(wave.dp) + ) + Spacer(Modifier.weight(1f)) + } + } +} + @Composable private fun FailedAudioMessageContent() { var audioNotAvailableDialog by remember { mutableStateOf(false) } @@ -495,7 +525,7 @@ private fun getPlayOrPauseIcon(audioMediaPlayingState: AudioMediaPlayingState): } @Suppress("MagicNumber") -private fun getDefaultWaveMask(): List = List(75) { 1 } +private fun getDefaultWaveMask(amount: Int): List = List(amount) { 1 } // helper wrapper class to format the time private data class AudioDuration(val totalDurationInMs: AudioState.TotalTimeInMs, val currentPositionInMs: Int) { @@ -598,14 +628,35 @@ private fun PreviewUploadedAudioMessageFailed() = WireTheme { ) } +@PreviewMultipleThemes +@Composable +private fun PreviewUploadedAudioMessageNarrow() = WireTheme { + Box(modifier = Modifier.width(150.dp)) { + UploadedAudioMessage( + audioState = PREVIEW_AUDIO_STATE.copy(audioMediaPlayingState = AudioMediaPlayingState.Fetching), + audioSpeed = AudioSpeed.NORMAL, + messageStyle = MessageStyle.NORMAL, + extension = "MP3", + size = 1024, + onPlayButtonClick = {}, + onSliderPositionChange = {}, + onAudioSpeedChange = {} + ) + } +} + @Suppress("MagicNumber") private val PREVIEW_AUDIO_STATE = AudioState( audioMediaPlayingState = AudioMediaPlayingState.SuccessfulFetching, currentPositionInMs = 0, totalTimeInMs = AudioState.TotalTimeInMs.Known(10000), wavesMask = listOf( - 32, 1, 24, 23, 13, 16, 9, 0, 4, 30, 23, 12, 14, 1, 7, 8, 0, 12, 32, 23, 34, 4, 16, 9, 0, 4, 30, 23, 12, - 14, 1, 7, 8, 0, 13, 16, 9, 0, 4, 30, 23, 12, 14, 1, 7, 8, 0, 12, 32, 23, 34, 4, 16, 13, 16, 9, 0, 4, 30, 23, 12, 14, 1, - 7, 8, 0, 12, 32, 23, 34, 4, 16, + 115, 166, 142, 163, 19, 70, 224, 5, 193, 73, 244, 64, 140, 255, 149, 58, 194, 244, 112, 128, 239, 51, 102, 83, 107, 148, 3, 147, + 151, 27, 124, 216, 208, 176, 248, 199, 47, 77, 154, 44, 73, 101, 33, 169, 17, 129, 97, 66, 17, 110, 247, 124, 237, 245, 43, 184, + 198, 196, 175, 195, 60, 66, 81, 109, 185, 206, 38, 130, 248, 206, 43, 156, 184, 9, 65, 40, 42, 18, 134, 41, 140, 234, 105, 130, + 42, 197, 103, 183, 82, 195, 24, 65, 45, 12, 136, 112, 204, 157, 123, 193, 193, 120, 51, 69, 136, 133, 37, 43, 233, 172, 63, 209, + 113, 175, 20, 211, 95, 131, 78, 198, 94, 239, 112, 67, 157, 106, 191, 75, 59, 115, 216, 21, 0, 57, 225, 2, 95, 88, 205, 104, 114, + 156, 24, 210, 69, 232, 141, 65, 102, 219, 36, 166, 252, 40, 129, 16, 240, 60, 33, 29, 219, 32, 5, 243, 39, 8, 89, 196, 250, 48, + 87, 181, 11, 165, 109, 151, 5, 46, 43, 36, 55, 108, 253, 153, 60, 45, 11, 225, 122, 244, 64, 241, 78, 44, 65, 137, 166, 10, 73, 75 ), ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/location/LocationMessageType.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/location/LocationMessageType.kt index 99af834a7c2..8d181093851 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/location/LocationMessageType.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/location/LocationMessageType.kt @@ -46,10 +46,10 @@ import com.wire.android.R import com.wire.android.model.Clickable import com.wire.android.ui.common.applyIf import com.wire.android.ui.common.clickable -import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.home.conversations.messages.item.MessageStyle import com.wire.android.ui.home.conversations.messages.item.isBubble +import com.wire.android.ui.home.conversations.messages.item.onBackground import com.wire.android.ui.home.conversations.messages.item.textColor import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions @@ -66,11 +66,7 @@ fun LocationMessageContent( ) { val linkColor = messageStyle.textColor() - val textColor = when (messageStyle) { - MessageStyle.BUBBLE_SELF -> colorsScheme().onPrimary - MessageStyle.BUBBLE_OTHER -> colorsScheme().onBackground - MessageStyle.NORMAL -> colorsScheme().onBackground - } + val textColor = messageStyle.onBackground() Column( modifier = modifier diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt index 19b09c5b44c..7c2264f1dbf 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt @@ -54,6 +54,7 @@ fun MultipartAttachmentsView( conversationId: ConversationId, attachments: List, messageStyle: MessageStyle, + onImageAttachmentClick: (String) -> Unit, modifier: Modifier = Modifier, accent: Accent = Accent.Unknown, viewModel: MultipartAttachmentsViewModel = hiltViewModel(key = conversationId.value), @@ -66,7 +67,13 @@ fun MultipartAttachmentsView( item = it, messageStyle = messageStyle, accent = accent, - onClick = { viewModel.onClick(it) }, + onClick = { + if (it.mimeType.startsWith("image/")) { + onImageAttachmentClick(it.uuid) + } else { + viewModel.onClick(it) + } + }, ) } } else { @@ -82,7 +89,13 @@ fun MultipartAttachmentsView( AttachmentsGrid( attachments = group.attachments, messageStyle = messageStyle, - onClick = { viewModel.onClick(it) }, + onClick = { + if (it.mimeType.startsWith("image/")) { + onImageAttachmentClick(it.uuid) + } else { + viewModel.onClick(it) + } + }, ) is MultipartAttachmentsViewModel.MultipartAttachmentGroup.Files -> diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/standalone/PdfAssetPreview.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/standalone/PdfAssetPreview.kt index f3e6dcfa666..9237f55490c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/standalone/PdfAssetPreview.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/standalone/PdfAssetPreview.kt @@ -47,6 +47,7 @@ import com.wire.android.ui.common.multipart.AssetSource import com.wire.android.ui.common.multipart.MultipartAttachmentUi import com.wire.android.ui.common.progress.WireLinearProgressIndicator import com.wire.android.ui.home.conversations.messages.item.MessageStyle +import com.wire.android.ui.home.conversations.messages.item.textColor import com.wire.android.ui.home.conversations.model.messagetypes.multipart.previewAvailable import com.wire.android.ui.home.conversations.model.messagetypes.multipart.previewImageModel import com.wire.android.ui.home.conversations.model.messagetypes.multipart.transferProgressColor @@ -128,6 +129,7 @@ internal fun PdfAssetPreview( .padding(start = dimensions().spacing8x, end = dimensions().spacing8x), text = it, style = MaterialTheme.wireTypography.body02, + color = messageStyle.textColor(), maxLines = 2, overflow = TextOverflow.Ellipsis ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/AddMembersSearchNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/AddMembersSearchNavArgs.kt index ec7c750df41..818082223ac 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/AddMembersSearchNavArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/AddMembersSearchNavArgs.kt @@ -21,5 +21,6 @@ import com.wire.kalium.logic.data.id.ConversationId data class AddMembersSearchNavArgs( val conversationId: ConversationId, - val isAppsUsageAllowed: Boolean + val isConversationAppsEnabled: Boolean, + val isSelfPartOfATeam: Boolean ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/EmptySearchQueryScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/EmptySearchQueryScreen.kt index ec2ec675c0a..6f75e61cc98 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/EmptySearchQueryScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/EmptySearchQueryScreen.kt @@ -37,15 +37,17 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.tooling.preview.Preview import com.wire.android.R import com.wire.android.ui.common.dimensions +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.CustomTabsHelper +import com.wire.android.util.ui.PreviewMultipleThemes @Composable fun EmptySearchQueryScreen( + modifier: Modifier = Modifier, text: String = stringResource(R.string.label_search_people_instruction), learnMoreTextToLink: Pair = Pair( stringResource(R.string.label_learn_more_searching_user), @@ -54,7 +56,7 @@ fun EmptySearchQueryScreen( ) { val context = LocalContext.current val (learnMoreText, learnMoreUrl) = learnMoreTextToLink - Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = modifier.fillMaxSize()) { Column( modifier = Modifier .align(Alignment.Center) @@ -82,8 +84,8 @@ fun EmptySearchQueryScreen( } } -@Preview +@PreviewMultipleThemes @Composable -fun EmptySearchQueryScreenPreview() { +fun EmptySearchUserScreenPreview() = WireTheme { EmptySearchQueryScreen() } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchAllServicesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchAllServicesScreen.kt deleted file mode 100644 index 0b0b7cb6d21..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchAllServicesScreen.kt +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.ui.home.conversations.search - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel -import com.wire.android.R -import com.wire.android.model.Clickable -import com.wire.android.ui.common.ArrowRightIcon -import com.wire.android.ui.common.rowitem.RowItemTemplate -import com.wire.android.ui.common.UserBadge -import com.wire.android.ui.common.avatar.UserProfileAvatar -import com.wire.android.ui.common.dimensions -import com.wire.android.ui.common.progress.CenteredCircularProgressBarIndicator -import com.wire.android.ui.home.conversations.search.widget.SearchFailureBox -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.util.ui.sectionWithElements -import com.wire.android.util.ui.PreviewMultipleThemes -import com.wire.kalium.logic.data.user.ConnectionState -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList - -@Composable -fun SearchAllServicesScreen( - searchQuery: String, - onServiceClicked: (Contact) -> Unit, - searchServicesViewModel: SearchServicesViewModel = hiltViewModel(), - lazyListState: LazyListState = rememberLazyListState(), -) { - LaunchedEffect(key1 = searchQuery) { - searchServicesViewModel.searchQueryChanged(searchQuery) - } - - SearchAllServicesContent( - searchQuery = searchServicesViewModel.state.searchQuery, - onServiceClicked = onServiceClicked, - result = searchServicesViewModel.state.result, - lazyListState = lazyListState, - isLoading = searchServicesViewModel.state.isLoading - ) -} - -@Composable -private fun SearchAllServicesContent( - searchQuery: String, - result: ImmutableList, - isLoading: Boolean, - onServiceClicked: (Contact) -> Unit, - lazyListState: LazyListState = rememberLazyListState() -) { - when { - isLoading -> CenteredCircularProgressBarIndicator() - - searchQuery.isBlank() && result.isEmpty() -> EmptySearchQueryScreen( - text = stringResource(R.string.label_search_apps_instruction), - learnMoreTextToLink = stringResource(R.string.label_learn_more_searching_app) to stringResource(R.string.url_how_to_add_apps) - ) - - searchQuery.isNotBlank() && result.isEmpty() -> SearchFailureBox(R.string.label_no_results_found) - - else -> SuccessServicesList( - searchQuery = searchQuery, - onServiceClicked = onServiceClicked, - services = result, - lazyListState = lazyListState - ) - } -} - -@Composable -private fun SuccessServicesList( - searchQuery: String, - services: List, - onServiceClicked: (Contact) -> Unit, - lazyListState: LazyListState = rememberLazyListState(), -) { - Column( - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - state = lazyListState, - modifier = Modifier - .weight(1f) - ) { - sectionWithElements( - items = services.associateBy { it.id } - ) { - val clickDescription = stringResource(id = R.string.content_description_open_service_label) - RowItemTemplate( - leadingIcon = { - Row { - UserProfileAvatar(it.avatarData) - } - }, - titleStartPadding = dimensions().spacing0x, - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - HighlightName( - name = it.name, - searchQuery = searchQuery, - modifier = Modifier.weight(weight = 1f, fill = false) - ) - UserBadge( - membership = it.membership, - connectionState = it.connectionState, - startPadding = dimensions().spacing8x - ) - } - }, - actions = { - Box( - modifier = Modifier - .wrapContentWidth() - .padding(end = dimensions().spacing4x) - ) { - ArrowRightIcon(Modifier.align(Alignment.TopEnd), R.string.content_description_empty) - } - }, - clickable = remember(it) { Clickable(onClickDescription = clickDescription) { onServiceClicked(it) } }, - modifier = Modifier.padding(start = dimensions().spacing8x) - ) - } - } - } -} - -@PreviewMultipleThemes -@Composable -fun PreviewSearchAllServicesScreen_Loading() = WireTheme { - SearchAllServicesContent("", persistentListOf(), true, {}) -} - -@PreviewMultipleThemes -@Composable -fun PreviewSearchAllServicesScreen_InitialResults() = WireTheme { - SearchAllServicesContent("", previewServiceList(count = 10).toPersistentList(), false, {}) -} - -@PreviewMultipleThemes -@Composable -fun PreviewSearchAllServicesScreen_EmptyInitialResults() = WireTheme { - SearchAllServicesContent("", persistentListOf(), false, {}) -} - -@PreviewMultipleThemes -@Composable -fun PreviewSearchAllServicesScreen_SearchResults() = WireTheme { - SearchAllServicesContent("Serv", previewServiceList(count = 10).toPersistentList(), false, {}) -} - -@PreviewMultipleThemes -@Composable -fun PreviewSearchAllServicesScreen_EmptySearchResults() = WireTheme { - SearchAllServicesContent("Serv", persistentListOf(), false, {}) -} - -private fun previewService(index: Int) = Contact( - id = index.toString(), - domain = "wire.com", - name = "Service nr $index", - handle = "service_$index", - connectionState = ConnectionState.NOT_CONNECTED, - membership = Membership.Service, -) - -private fun previewServiceList(count: Int): List = buildList { - repeat(count) { index -> add(previewService(index)) } -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndServicesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndAppsScreen.kt similarity index 94% rename from app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndServicesScreen.kt rename to app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndAppsScreen.kt index a4f1b030490..d61008b92fd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndServicesScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndAppsScreen.kt @@ -57,6 +57,7 @@ import com.wire.android.ui.common.search.rememberSearchbarState import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.topappbar.search.SearchTopBar +import com.wire.android.ui.home.conversations.search.apps.SearchAppsScreen import com.wire.android.ui.home.newconversation.common.ContinueButton import com.wire.android.ui.home.newconversation.common.CreateRegularGroupOrChannelButtons import com.wire.android.ui.home.newconversation.model.Contact @@ -68,28 +69,29 @@ import kotlinx.coroutines.launch @Suppress("ComplexMethod") @OptIn(ExperimentalFoundationApi::class) @Composable -fun SearchUsersAndServicesScreen( +fun SearchUsersAndAppsScreen( searchTitle: String, selectedContacts: ImmutableSet, onContactChecked: (Boolean, Contact) -> Unit, onOpenUserProfile: (Contact) -> Unit, - onServiceClicked: (Contact) -> Unit, + onAppClicked: (Contact) -> Unit, onClose: () -> Unit, screenType: SearchPeopleScreenType, shouldShowChannelPromotion: Boolean, isUserAllowedToCreateChannels: Boolean, modifier: Modifier = Modifier, isGroupSubmitVisible: Boolean = true, - isAppDiscoveryAllowed: Boolean = false, + isAppsTabVisible: Boolean = false, + isConversationAppsEnabled: Boolean = true, initialPage: SearchPeopleTabItem = SearchPeopleTabItem.PEOPLE, onContinue: () -> Unit = {}, onCreateNewGroup: () -> Unit = {}, - onCreateNewChannel: () -> Unit = {} + onCreateNewChannel: () -> Unit = {}, ) { val searchBarState = rememberSearchbarState() val scope = rememberCoroutineScope() - val tabs = remember(isAppDiscoveryAllowed) { - if (isAppDiscoveryAllowed) SearchPeopleTabItem.entries else listOf(SearchPeopleTabItem.PEOPLE) + val tabs = remember(isAppsTabVisible) { + if (isAppsTabVisible) SearchPeopleTabItem.entries else listOf(SearchPeopleTabItem.PEOPLE) } val pagerState = rememberPagerState( initialPage = tabs.indexOf(initialPage), @@ -104,7 +106,7 @@ fun SearchUsersAndServicesScreen( rememberLazyListState() } - val searchBarTitle = if (isAppDiscoveryAllowed) { + val searchBarTitle = if (isAppsTabVisible) { stringResource(R.string.label_search_people_or_apps) } else { stringResource(R.string.label_search_people) @@ -147,7 +149,7 @@ fun SearchUsersAndServicesScreen( ) }, topBarFooter = { - if (isAppDiscoveryAllowed) { + if (isAppsTabVisible) { WireTabRow( tabs = SearchPeopleTabItem.entries, selectedTabIndex = currentTabState, @@ -184,10 +186,11 @@ fun SearchUsersAndServicesScreen( } SearchPeopleTabItem.SERVICES -> { - SearchAllServicesScreen( + SearchAppsScreen( searchQuery = searchBarState.searchQueryTextState.text.toString(), - onServiceClicked = onServiceClicked, + onServiceClicked = onAppClicked, lazyListState = lazyListStates[pageIndex], + isConversationAppsEnabled = isConversationAppsEnabled, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersSearchScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersSearchScreen.kt index e308cdd639e..463a1832084 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersSearchScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersSearchScreen.kt @@ -28,7 +28,7 @@ import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.destinations.ServiceDetailsScreenDestination import com.wire.android.ui.home.conversations.search.AddMembersSearchNavArgs import com.wire.android.ui.home.conversations.search.SearchPeopleScreenType -import com.wire.android.ui.home.conversations.search.SearchUsersAndServicesScreen +import com.wire.android.ui.home.conversations.search.SearchUsersAndAppsScreen import com.wire.android.ui.home.newconversation.model.Contact import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.user.BotService @@ -45,7 +45,7 @@ fun AddMembersSearchScreen( if (addMembersToConversationViewModel.newGroupState.isCompleted) { navigator.navigateBack() } - SearchUsersAndServicesScreen( + SearchUsersAndAppsScreen( searchTitle = stringResource(id = R.string.label_add_participants), onOpenUserProfile = { contact: Contact -> OtherUserProfileScreenDestination(QualifiedID(contact.id, contact.domain)) @@ -55,14 +55,15 @@ fun AddMembersSearchScreen( onContinue = addMembersToConversationViewModel::addMembersToConversation, isGroupSubmitVisible = true, onClose = navigator::navigateBack, - onServiceClicked = { contact: Contact -> + onAppClicked = { contact: Contact -> ServiceDetailsScreenDestination(BotService(contact.id, contact.domain), navArgs.conversationId) .let { navigator.navigate(NavigationCommand(it)) } }, screenType = SearchPeopleScreenType.CONVERSATION_DETAILS, selectedContacts = addMembersToConversationViewModel.newGroupState.selectedContacts, - isAppDiscoveryAllowed = navArgs.isAppsUsageAllowed, + isAppsTabVisible = navArgs.isSelfPartOfATeam, isUserAllowedToCreateChannels = false, shouldShowChannelPromotion = false, + isConversationAppsEnabled = navArgs.isConversationAppsEnabled, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModel.kt index aad1c3d29fe..7fcd6c3851e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModel.kt @@ -49,6 +49,7 @@ class AddMembersToConversationViewModel @Inject constructor( var newGroupState: AddMembersToConversationState by mutableStateOf(AddMembersToConversationState()) private set + fun addMembersToConversation() { viewModelScope.launch { withContext(dispatchers.io()) { @@ -63,14 +64,14 @@ class AddMembersToConversationViewModel @Inject constructor( } fun updateSelectedContacts(selected: Boolean, contact: Contact) { - if (selected) { - newGroupState = newGroupState.copy(selectedContacts = (newGroupState.selectedContacts + contact).toImmutableSet()) + newGroupState = if (selected) { + newGroupState.copy(selectedContacts = (newGroupState.selectedContacts + contact).toImmutableSet()) } else { - newGroupState = newGroupState.copy( + newGroupState.copy( selectedContacts = newGroupState.selectedContacts.filterNot { - it.id == contact.id && - it.domain == contact.domain - }.toImmutableSet() + it.id == contact.id && + it.domain == contact.domain + }.toImmutableSet() ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/AppsContentState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/AppsContentState.kt new file mode 100644 index 00000000000..c545ee312bc --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/AppsContentState.kt @@ -0,0 +1,30 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.search.apps + +/** + * Represents the different states of the apps content tab in the add members search screen. + */ +enum class AppsContentState { + LOADING, + TEAM_NOT_ALLOWED, + EMPTY_INITIAL, + EMPTY_SEARCH, + SHOW_RESULTS, + APPS_NOT_ENABLED_FOR_CONVERSATION +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/EmptySearchAppsContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/EmptySearchAppsContent.kt new file mode 100644 index 00000000000..b02dbb8c269 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/EmptySearchAppsContent.kt @@ -0,0 +1,74 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.search.apps + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.material3.MaterialTheme +import androidx.compose.material3.Text +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.text.style.TextAlign +import com.wire.android.R +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.wireTypography + +@Composable +fun EmptySearchAppsContent( + isSelfATeamAdmin: Boolean, + modifier: Modifier = Modifier +) { + val (title, description) = if (isSelfATeamAdmin) { + Pair( + stringResource(R.string.search_results_apps_empty_title), + stringResource(R.string.search_results_apps_empty_description_team_admin) + ) + } else { + Pair( + stringResource(R.string.search_results_apps_empty_title), + stringResource(R.string.search_results_apps_empty_description_non_team_admin) + ) + } + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(dimensions().spacing16x), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = title, + style = MaterialTheme.wireTypography.body02, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(dimensions().spacing16x)) + Text( + text = description, + style = MaterialTheme.wireTypography.body01, + textAlign = TextAlign.Center + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/EmptySearchDisabledByConversationContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/EmptySearchDisabledByConversationContent.kt new file mode 100644 index 00000000000..0f99aad1fb5 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/EmptySearchDisabledByConversationContent.kt @@ -0,0 +1,52 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.search.apps + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.text.style.TextAlign +import com.wire.android.R +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.wireTypography + +@Composable +fun EmptySearchDisabledByConversationContent(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(dimensions().spacing16x), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.search_results_apps_empty_description_disabled_for_conversation), + style = MaterialTheme.wireTypography.body01, + textAlign = TextAlign.Center + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/SearchAppsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/SearchAppsScreen.kt new file mode 100644 index 00000000000..06df4282c0e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/SearchAppsScreen.kt @@ -0,0 +1,334 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.search.apps + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.wire.android.R +import com.wire.android.model.Clickable +import com.wire.android.ui.common.ArrowRightIcon +import com.wire.android.ui.common.UserBadge +import com.wire.android.ui.common.avatar.UserProfileAvatar +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.progress.CenteredCircularProgressBarIndicator +import com.wire.android.ui.common.rowitem.RowItemTemplate +import com.wire.android.ui.common.upgradetoapps.UpgradeToGetAppsBanner +import com.wire.android.ui.home.conversations.search.HighlightName +import com.wire.android.ui.home.conversations.search.widget.SearchFailureBox +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.util.ui.PreviewMultipleThemes +import com.wire.android.util.ui.sectionWithElements +import com.wire.kalium.logic.data.user.ConnectionState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +@Composable +fun SearchAppsScreen( + searchQuery: String, + onServiceClicked: (Contact) -> Unit, + isConversationAppsEnabled: Boolean, + searchAppsViewModel: SearchAppsViewModel = hiltViewModel(), + lazyListState: LazyListState = rememberLazyListState() +) { + LaunchedEffect(key1 = searchQuery) { + searchAppsViewModel.searchQueryChanged(searchQuery) + } + + with(searchAppsViewModel) { + SearchAllAppsContent( + searchQuery = state.searchQuery, + onServiceClicked = onServiceClicked, + result = state.result, + isLoading = state.isLoading, + isTeamAllowedToUseApps = state.isTeamAllowedToUseApps, + isSelfATeamAdmin = state.isSelfATeamAdmin, + lazyListState = lazyListState, + isConversationAppsEnabled = isConversationAppsEnabled + ) + } +} + +@Composable +private fun SearchAllAppsContent( + searchQuery: String, + result: ImmutableList, + isLoading: Boolean, + onServiceClicked: (Contact) -> Unit, + isTeamAllowedToUseApps: Boolean, + isSelfATeamAdmin: Boolean, + isConversationAppsEnabled: Boolean, + lazyListState: LazyListState = rememberLazyListState() +) { + val appsContentState by rememberAppsContentState( + isConversationAppsEnabled = isConversationAppsEnabled, + isLoading = isLoading, + isTeamAllowedToUseApps = isTeamAllowedToUseApps, + searchQuery = searchQuery, + result = result + ) + // Reset scroll position only when search query changes (not on loading/state changes) + LaunchedEffect(searchQuery) { + if (searchQuery.isNotBlank()) { + lazyListState.scrollToItem(0) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + Crossfade( + targetState = appsContentState, + label = "SearchAppsContentTransition", + modifier = Modifier.fillMaxSize() + ) { state -> + // order here matters for Crossfade animation and priority of states + when (state) { + AppsContentState.LOADING -> { + CenteredCircularProgressBarIndicator() + } + + AppsContentState.APPS_NOT_ENABLED_FOR_CONVERSATION -> { + EmptySearchDisabledByConversationContent() + } + + AppsContentState.TEAM_NOT_ALLOWED -> { + UpgradeToGetAppsBanner() + } + + AppsContentState.EMPTY_INITIAL -> { + EmptySearchAppsContent(isSelfATeamAdmin = isSelfATeamAdmin) + } + + AppsContentState.EMPTY_SEARCH -> { + SearchFailureBox(R.string.label_no_results_found) + } + + AppsContentState.SHOW_RESULTS -> { + AppsList( + searchQuery = searchQuery, + onServiceClicked = onServiceClicked, + apps = result, + lazyListState = lazyListState + ) + } + } + } + } +} + +@Composable +private fun rememberAppsContentState( + isConversationAppsEnabled: Boolean, + isLoading: Boolean, + isTeamAllowedToUseApps: Boolean, + searchQuery: String, + result: ImmutableList +): State = remember(isConversationAppsEnabled, isLoading, isTeamAllowedToUseApps, searchQuery, result) { + derivedStateOf { + when { + isLoading -> AppsContentState.LOADING + !isTeamAllowedToUseApps -> AppsContentState.TEAM_NOT_ALLOWED + !isConversationAppsEnabled -> AppsContentState.APPS_NOT_ENABLED_FOR_CONVERSATION + searchQuery.isBlank() && result.isEmpty() -> AppsContentState.EMPTY_INITIAL + searchQuery.isNotBlank() && result.isEmpty() -> AppsContentState.EMPTY_SEARCH + else -> AppsContentState.SHOW_RESULTS + } + } +} + +@Composable +private fun AppsList( + searchQuery: String, + apps: List, + onServiceClicked: (Contact) -> Unit, + lazyListState: LazyListState = rememberLazyListState(), +) { + LazyColumn( + state = lazyListState, + modifier = Modifier + + ) { + sectionWithElements( + items = apps.associateBy { it.id } + ) { + val clickDescription = stringResource(id = R.string.content_description_open_service_label) + RowItemTemplate( + leadingIcon = { + Row { + UserProfileAvatar(it.avatarData) + } + }, + titleStartPadding = dimensions().spacing0x, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + HighlightName( + name = it.name, + searchQuery = searchQuery, + modifier = Modifier.weight(weight = 1f, fill = false) + ) + UserBadge( + membership = it.membership, + connectionState = it.connectionState, + startPadding = dimensions().spacing8x + ) + } + }, + actions = { + Box( + modifier = Modifier + .wrapContentWidth() + .padding(end = dimensions().spacing4x) + ) { + ArrowRightIcon(Modifier.align(Alignment.TopEnd), R.string.content_description_empty) + } + }, + clickable = remember(it) { Clickable(onClickDescription = clickDescription) { onServiceClicked(it) } }, + modifier = Modifier.padding(start = dimensions().spacing8x) + ) + } + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewSearchAllServicesScreen_TeamNotEnabledForApps() = WireTheme { + SearchAllAppsContent( + searchQuery = "", + result = persistentListOf(), + isLoading = false, + onServiceClicked = {}, + isTeamAllowedToUseApps = false, + isSelfATeamAdmin = true, + isConversationAppsEnabled = true + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewSearchAllServicesScreen_InitialResults() = WireTheme { + SearchAllAppsContent( + searchQuery = "", + result = previewServiceList(count = 10).toPersistentList(), + isLoading = false, + onServiceClicked = {}, + isTeamAllowedToUseApps = true, + isSelfATeamAdmin = true, + isConversationAppsEnabled = true + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewSearchAllServicesScreen_EmptyInitialResults_TeamAdmin() = WireTheme { + SearchAllAppsContent( + searchQuery = "", + result = persistentListOf(), + isLoading = false, + onServiceClicked = {}, + isTeamAllowedToUseApps = true, + isSelfATeamAdmin = true, + isConversationAppsEnabled = true + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewSearchAllServicesScreen_EmptyInitialResults_NonTeamAdmin() = WireTheme { + SearchAllAppsContent( + searchQuery = "", + result = persistentListOf(), + isLoading = false, + onServiceClicked = {}, + isTeamAllowedToUseApps = true, + isSelfATeamAdmin = false, + isConversationAppsEnabled = true + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewSearchAllServicesScreen_SearchResults() = WireTheme { + SearchAllAppsContent( + searchQuery = "Serv", + result = previewServiceList(count = 10).toPersistentList(), + isLoading = false, + onServiceClicked = {}, + isTeamAllowedToUseApps = true, + isSelfATeamAdmin = true, + isConversationAppsEnabled = true + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewSearchAllServicesScreen_EmptySearchResults() = WireTheme { + SearchAllAppsContent( + searchQuery = "Serv", + result = persistentListOf(), + isLoading = false, + onServiceClicked = {}, + isTeamAllowedToUseApps = true, + isSelfATeamAdmin = true, + isConversationAppsEnabled = true + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewSearchAllServicesScreen_EmptySearchResultsDisabledInConversation() = WireTheme { + SearchAllAppsContent( + searchQuery = "Serv", + result = persistentListOf(), + isLoading = false, + onServiceClicked = {}, + isTeamAllowedToUseApps = true, + isSelfATeamAdmin = true, + isConversationAppsEnabled = false + ) +} + +private fun previewService(index: Int) = Contact( + id = index.toString(), + domain = "wire.com", + name = "Service nr $index", + handle = "service_$index", + connectionState = ConnectionState.NOT_CONNECTED, + membership = Membership.Service, +) + +private fun previewServiceList(count: Int): List = buildList { + repeat(count) { index -> add(previewService(index)) } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchServicesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/SearchAppsViewModel.kt similarity index 69% rename from app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchServicesViewModel.kt rename to app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/SearchAppsViewModel.kt index c13478ca29d..02103509bc9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchServicesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/apps/SearchAppsViewModel.kt @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2024 Wire Swiss GmbH + * Copyright (C) 2025 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 @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.conversations.search +package com.wire.android.ui.home.conversations.search.apps import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -26,14 +26,18 @@ import com.wire.android.mapper.ContactMapper import com.wire.android.ui.common.DEFAULT_SEARCH_QUERY_DEBOUNCE import com.wire.android.ui.home.newconversation.model.Contact import com.wire.android.util.EMPTY +import com.wire.kalium.logic.data.user.type.isTeamAdmin +import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppsAllowedForUsageUseCase import com.wire.kalium.logic.feature.service.ObserveAllServicesUseCase import com.wire.kalium.logic.feature.service.SearchServicesByNameUseCase +import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onStart @@ -41,10 +45,12 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class SearchServicesViewModel @Inject constructor( +class SearchAppsViewModel @Inject constructor( private val getAllServices: ObserveAllServicesUseCase, private val contactMapper: ContactMapper, private val searchServicesByName: SearchServicesByNameUseCase, + private val isAppsAllowedForUsage: ObserveIsAppsAllowedForUsageUseCase, + private val observeSelfUser: ObserveSelfUserUseCase ) : ViewModel() { private val searchQueryTextFlow = MutableStateFlow(String.EMPTY) var state: SearchServicesState by mutableStateOf(SearchServicesState(isLoading = true)) @@ -52,12 +58,20 @@ class SearchServicesViewModel @Inject constructor( init { viewModelScope.launch { - searchQueryTextFlow - .debounce(DEFAULT_SEARCH_QUERY_DEBOUNCE) - .onStart { emit(String.EMPTY) } - .collectLatest { query -> + combine( + observeSelfUser(), + isAppsAllowedForUsage(), + searchQueryTextFlow.onStart { emit(String.EMPTY) } + ) { selfUser, isEnabled, query -> + Triple(selfUser, isEnabled, query) + }.debounce(DEFAULT_SEARCH_QUERY_DEBOUNCE).collectLatest { (selfUser, isEnabled, query) -> + state = state.copy(isTeamAllowedToUseApps = isEnabled, isSelfATeamAdmin = selfUser.userType.isTeamAdmin()) + if (isEnabled) { search(query) + } else { + state = state.copy(isLoading = false, result = persistentListOf()) } + } } } @@ -82,5 +96,7 @@ class SearchServicesViewModel @Inject constructor( data class SearchServicesState( val result: ImmutableList = persistentListOf(), val searchQuery: String = String.EMPTY, + val isTeamAllowedToUseApps: Boolean = false, + val isSelfATeamAdmin: Boolean = false, val isLoading: Boolean = false, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt index 0b260ad8fc3..446a9967324 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt @@ -29,6 +29,7 @@ import com.wire.android.appLogger import com.wire.android.feature.analytics.AnonymousAnalyticsManager import com.wire.android.feature.analytics.model.AnalyticsEvent import com.wire.android.media.PingRinger +import com.wire.android.media.audiomessage.toNormalizedLoudness import com.wire.android.model.SnackBarMessage import com.wire.android.ui.home.conversations.AssetTooLargeDialogState import com.wire.android.ui.home.conversations.ConversationNavArgs @@ -297,10 +298,11 @@ class SendMessageViewModel @Inject constructor( ) { when ( val result = handleUriAsset.invoke( - uri = attachmentUri.uri, - saveToDeviceIfInvalid = attachmentUri.saveToDeviceIfInvalid, - specifiedMimeType = attachmentUri.mimeType - ) + uri = attachmentUri.uri, + saveToDeviceIfInvalid = attachmentUri.saveToDeviceIfInvalid, + specifiedMimeType = attachmentUri.mimeType, + audioWavesMask = attachmentUri.audioWavesMask, + ) ) { is HandleUriAssetUseCase.Result.Failure.AssetTooLarge -> { assetTooLargeDialogState = AssetTooLargeDialogState.Visible( @@ -502,6 +504,7 @@ class SendMessageViewModel @Inject constructor( assetHeight = assetHeight, assetWidth = assetWidth, audioLengthInMs = audioLengthInMs, + audioNormalizedLoudness = audioWavesMask?.toNormalizedLoudness() ) private companion object { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCase.kt index 19e43803dbe..eb04dac45c4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCase.kt @@ -39,6 +39,7 @@ class HandleUriAssetUseCase @Inject constructor( uri: Uri, saveToDeviceIfInvalid: Boolean = false, specifiedMimeType: String? = null, // specify a particular mimetype, otherwise it will be taken from the uri / file extension + audioWavesMask: List? = null, ): Result = withContext(dispatchers.io()) { if (!isValidUriSchema(uri)) { return@withContext Result.Failure.Unknown @@ -49,6 +50,7 @@ class HandleUriAssetUseCase @Inject constructor( attachmentUri = uri, assetDestinationPath = tempAssetPath, specifiedMimeType = specifiedMimeType, + audioWavesMask = audioWavesMask, ) if (assetBundle != null) { // The max limit for sending assets changes between user and asset types. diff --git a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryNavArgs.kt index 4c5c70ad2f8..c7e4f7b23d6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryNavArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryNavArgs.kt @@ -26,7 +26,8 @@ data class MediaGalleryNavArgs( val messageId: String, val isSelfAsset: Boolean, val isEphemeral: Boolean, - val messageOptionsEnabled: Boolean + val messageOptionsEnabled: Boolean, + val cellAssetId: String?, ) @Parcelize @@ -34,7 +35,8 @@ data class MediaGalleryNavBackArgs( val messageId: String, val emoji: String? = null, val isSelfAsset: Boolean = false, - val mediaGalleryActionType: MediaGalleryActionType + val mediaGalleryActionType: MediaGalleryActionType, + val cellAssetId: String? = null, ) : Parcelable enum class MediaGalleryActionType { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt index d8925d607b3..5f3daa79915 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt @@ -18,48 +18,57 @@ package com.wire.android.ui.home.gallery +import android.content.Context +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel +import coil.annotation.ExperimentalCoilApi import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.R -import com.wire.android.model.ImageAsset +import com.wire.android.feature.cells.ui.destinations.PublicLinkScreenDestination +import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.annotation.app.WireDestination import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout import com.wire.android.ui.common.bottomsheet.WireModalSheetState import com.wire.android.ui.common.bottomsheet.WireSheetValue import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState -import com.wire.android.ui.common.bottomsheet.show import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.visbility.rememberVisibilityState +import com.wire.android.ui.edit.DeleteItemMenuOption +import com.wire.android.ui.edit.DownloadAssetExternallyOption +import com.wire.android.ui.edit.MessageDetailsMenuOption +import com.wire.android.ui.edit.ReactionOption +import com.wire.android.ui.edit.ReplyMessageOption +import com.wire.android.ui.edit.ShareAssetMenuOption +import com.wire.android.ui.edit.SharePublicLinkMenuOption import com.wire.android.ui.home.conversations.MediaGallerySnackbarMessages import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialog -import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogState -import com.wire.android.ui.home.conversations.edit.assetMessageOptionsMenuItems -import com.wire.android.ui.home.conversations.edit.assetOptionsMenuItems import com.wire.android.ui.home.conversations.mock.mockedPrivateAsset import com.wire.android.ui.theme.WireTheme import com.wire.android.util.permission.rememberWriteStoragePermissionFlow +import com.wire.android.util.startFileShareIntent import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.android.util.ui.openDownloadFolder +@OptIn(ExperimentalCoilApi::class) @WireDestination( navArgsDelegate = MediaGalleryNavArgs::class, style = PopUpNavigationAnimation::class, @@ -75,11 +84,9 @@ fun MediaGalleryScreen( rememberVisibilityState() val viewModelState = mediaGalleryViewModel.mediaGalleryViewState - val bottomSheetState: WireModalSheetState = rememberWireModalSheetState() val context = LocalContext.current val onSaveImageWriteStorageRequest = rememberWriteStoragePermissionFlow( onPermissionGranted = { - bottomSheetState.hide() mediaGalleryViewModel.saveImageToExternalStorage() }, onPermissionDenied = { /** Nothing to do **/ }, @@ -98,10 +105,6 @@ fun MediaGalleryScreen( hideDialog = permissionPermanentlyDeniedDialogState::dismiss ) - LaunchedEffect(viewModelState.messageDeleted) { - if (viewModelState.messageDeleted) navigator.navigateBack() - } - DeleteMessageDialog( dialogState = mediaGalleryViewModel.deleteMessageDialogState, deleteMessage = mediaGalleryViewModel::deleteMessage, @@ -109,53 +112,18 @@ fun MediaGalleryScreen( MediaGalleryContent( state = viewModelState, - imageAsset = mediaGalleryViewModel.imageAsset, onCloseClick = navigator::navigateBack, - onOptionsClick = bottomSheetState::show, + onOptionsClick = mediaGalleryViewModel::onOptionsClick, modifier = modifier, ) - MediaGalleryOptionsBottomSheetLayout( - sheetState = bottomSheetState, - isEphemeral = mediaGalleryViewModel.mediaGalleryViewState.isEphemeral, - messageBottomSheetOptionsEnabled = viewModelState.messageBottomSheetOptionsEnabled, - deleteAsset = { - with(mediaGalleryViewModel.imageAsset) { - mediaGalleryViewModel.deleteMessageDialogState.show(DeleteMessageDialogState(isSelfAsset, messageId, conversationId)) - } - }, - showDetails = { - resultNavigator.setResult( - MediaGalleryNavBackArgs( - messageId = mediaGalleryViewModel.imageAsset.messageId, - isSelfAsset = mediaGalleryViewModel.imageAsset.isSelfAsset, - mediaGalleryActionType = MediaGalleryActionType.DETAIL - ) - ) - resultNavigator.navigateBack() - }, - shareAsset = { mediaGalleryViewModel.shareAsset(context) }, - reply = { - resultNavigator.setResult( - MediaGalleryNavBackArgs( - messageId = mediaGalleryViewModel.imageAsset.messageId, - mediaGalleryActionType = MediaGalleryActionType.REPLY - ) - ) - resultNavigator.navigateBack() - }, - react = { emoji -> - resultNavigator.setResult( - MediaGalleryNavBackArgs( - messageId = mediaGalleryViewModel.imageAsset.messageId, - emoji = emoji, - mediaGalleryActionType = MediaGalleryActionType.REACT - ) - ) - resultNavigator.navigateBack() - }, - downloadAsset = onSaveImageWriteStorageRequest::launch - ) + if (viewModelState.menuItems.isNotEmpty()) { + MediaGalleryOptionsBottomSheetLayout( + menuItems = viewModelState.menuItems, + onMenuIntent = { mediaGalleryViewModel.onMenuIntent(it) }, + onDismiss = { mediaGalleryViewModel.onOptionsDismissed() }, + ) + } SnackBarMessageHandler(mediaGalleryViewModel.snackbarMessage) { messageCode -> when (messageCode) { @@ -164,12 +132,70 @@ fun MediaGalleryScreen( } } } + + HandleActions(mediaGalleryViewModel.actions) { action -> + when (action) { + is MediaGalleryAction.Share -> context.startFileShareIntent(action.path, action.assetName) + is MediaGalleryAction.ShowDetails -> { + resultNavigator.setResult( + MediaGalleryNavBackArgs( + messageId = action.messageId, + isSelfAsset = action.isSelfAsset, + mediaGalleryActionType = MediaGalleryActionType.DETAIL + ) + ) + resultNavigator.navigateBack() + } + + is MediaGalleryAction.React -> { + resultNavigator.setResult( + MediaGalleryNavBackArgs( + messageId = action.messageId, + emoji = action.emoji, + mediaGalleryActionType = MediaGalleryActionType.REACT + ) + ) + resultNavigator.navigateBack() + } + + is MediaGalleryAction.Reply -> { + resultNavigator.setResult( + MediaGalleryNavBackArgs( + messageId = action.messageId, + mediaGalleryActionType = MediaGalleryActionType.REPLY + ) + ) + resultNavigator.navigateBack() + } + + MediaGalleryAction.Download -> { onSaveImageWriteStorageRequest.launch() } + is MediaGalleryAction.SharePublicLink -> { + navigator.navigate( + NavigationCommand( + PublicLinkScreenDestination( + assetId = action.assetId, + fileName = action.assetName, + publicLinkId = action.publicLinkId, + isFolder = false, + ) + ) + ) + mediaGalleryViewModel.onOptionsDismissed() + } + + MediaGalleryAction.ShowError -> showErrorMessage(context) + MediaGalleryAction.Close -> navigator.navigateBack() + } + } +} + +fun showErrorMessage(context: Context) { + Toast.makeText(context, R.string.label_general_error, Toast.LENGTH_SHORT).show() } @Composable private fun MediaGalleryContent( state: MediaGalleryViewState, - imageAsset: ImageAsset.Remote, onCloseClick: () -> Unit, onOptionsClick: () -> Unit, modifier: Modifier = Modifier, @@ -192,11 +218,13 @@ private fun MediaGalleryContent( .fillMaxHeight() .background(colorsScheme().surface) ) { - ZoomableImage( - modifier = Modifier.align(Alignment.Center), - imageAsset = imageAsset, - contentDescription = stringResource(R.string.content_description_image_message) - ) + state.imageAsset?.let { + ZoomableImage( + modifier = Modifier.align(Alignment.Center), + image = it, + contentDescription = stringResource(R.string.content_description_image_message) + ) + } } } ) @@ -204,47 +232,47 @@ private fun MediaGalleryContent( @Composable private fun MediaGalleryOptionsBottomSheetLayout( - sheetState: WireModalSheetState, - isEphemeral: Boolean, - messageBottomSheetOptionsEnabled: Boolean, - deleteAsset: () -> Unit, - showDetails: () -> Unit, - shareAsset: () -> Unit, - reply: () -> Unit, - react: (String) -> Unit, - downloadAsset: () -> Unit, + menuItems: List, + onMenuIntent: (MenuIntent) -> Unit, + onDismiss: () -> Unit, ) { - val onDeleteClick: () -> Unit = remember { { sheetState.hide(deleteAsset) } } - val onShowDetailsClick: () -> Unit = remember { { sheetState.hide(showDetails) } } - val onShareAssetClick: () -> Unit = remember { { sheetState.hide(shareAsset) } } - val onReplyClick: () -> Unit = remember { { sheetState.hide(reply) } } - val onReactClick: (String) -> Unit = remember { { emoji -> sheetState.hide { react(emoji) } } } + + val sheetState: WireModalSheetState = rememberWireModalSheetState(WireSheetValue.Expanded(Unit)) + val onOptionsClick: (MenuIntent) -> Unit = remember { { sheetState.hide { onMenuIntent(it) } } } + + val menuItems: List<@Composable () -> Unit> = buildList { + menuItems.forEach { item -> + when (item) { + MediaGalleryMenuItem.REACT -> add { + ReactionOption(emptySet(), { onOptionsClick(MenuIntent.React(it)) }) + } + MediaGalleryMenuItem.SHOW_DETAILS -> add { + MessageDetailsMenuOption { onOptionsClick(MenuIntent.ShowDetails) } + } + MediaGalleryMenuItem.REPLY -> add { + ReplyMessageOption { onOptionsClick(MenuIntent.Reply) } + } + MediaGalleryMenuItem.DOWNLOAD -> add { + DownloadAssetExternallyOption { onOptionsClick(MenuIntent.Download) } + } + MediaGalleryMenuItem.SHARE -> add { + ShareAssetMenuOption { onOptionsClick(MenuIntent.Share) } + } + MediaGalleryMenuItem.SHARE_PUBLIC_LINK -> add { + SharePublicLinkMenuOption { onOptionsClick(MenuIntent.Share) } + } + MediaGalleryMenuItem.DELETE -> add { + DeleteItemMenuOption { onOptionsClick(MenuIntent.Delete) } + } + } + } + } + WireModalSheetLayout( sheetState = sheetState, + onDismissRequest = onDismiss, sheetContent = { - WireMenuModalSheetContent( - menuItems = if (messageBottomSheetOptionsEnabled) { - assetMessageOptionsMenuItems( - isUploading = false, - isEphemeral = isEphemeral, - onReplyClick = onReplyClick, - onReactionClick = onReactClick, - onDetailsClick = onShowDetailsClick, - onDeleteClick = onDeleteClick, - onShareAsset = onShareAssetClick, - onDownloadAsset = downloadAsset, - ownReactions = setOf() - ) - } else { - assetOptionsMenuItems( - isUploading = false, - isEphemeral = isEphemeral, - onDeleteClick = onDeleteClick, - onShareAsset = onShareAssetClick, - onDownloadAsset = downloadAsset, - ) - } - ) + WireMenuModalSheetContent(menuItems = menuItems) } ) } @@ -254,10 +282,9 @@ private fun MediaGalleryOptionsBottomSheetLayout( fun PreviewMediaGalleryScreen() = WireTheme { MediaGalleryContent( state = MediaGalleryViewState( + imageAsset = MediaGalleryImage.PrivateAsset(mockedPrivateAsset()), screenTitle = "Media Gallery", - messageBottomSheetOptionsEnabled = true, ), - imageAsset = mockedPrivateAsset(), onCloseClick = {}, onOptionsClick = {} ) @@ -267,14 +294,8 @@ fun PreviewMediaGalleryScreen() = WireTheme { @Composable fun PreviewMediaGalleryOptionsBottomSheetLayout() = WireTheme { MediaGalleryOptionsBottomSheetLayout( - sheetState = rememberWireModalSheetState(initialValue = WireSheetValue.Expanded(Unit)), - isEphemeral = false, - messageBottomSheetOptionsEnabled = true, - deleteAsset = {}, - showDetails = {}, - shareAsset = {}, - reply = {}, - react = {}, - downloadAsset = {} - ) + onMenuIntent = {}, + onDismiss = {}, + menuItems = emptyList(), + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt index c47b0bb9848..fc424c8e1b5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt @@ -18,25 +18,26 @@ package com.wire.android.ui.home.gallery -import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.model.ImageAsset +import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.common.visbility.VisibilityState import com.wire.android.ui.home.conversations.MediaGallerySnackbarMessages import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogState import com.wire.android.ui.navArgs import com.wire.android.util.FileManager import com.wire.android.util.dispatchers.DispatcherProvider -import com.wire.android.util.startFileShareIntent +import com.wire.kalium.cells.domain.usecase.GetCellFileUseCase +import com.wire.kalium.cells.domain.usecase.GetMessageAttachmentUseCase import com.wire.kalium.common.functional.onFailure import com.wire.kalium.common.functional.onSuccess import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.message.CellAssetContent import com.wire.kalium.logic.feature.asset.GetMessageAssetUseCase import com.wire.kalium.logic.feature.asset.MessageAssetResult.Success import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase @@ -59,25 +60,18 @@ class MediaGalleryViewModel @Inject constructor( private val dispatchers: DispatcherProvider, private val getImageData: GetMessageAssetUseCase, private val fileManager: FileManager, - private val deleteMessage: DeleteMessageUseCase -) : ViewModel() { + private val deleteMessage: DeleteMessageUseCase, + private val getAttachment: GetMessageAttachmentUseCase, + private val getCellNode: GetCellFileUseCase, +) : ActionsViewModel() { private val mediaGalleryNavArgs: MediaGalleryNavArgs = savedStateHandle.navArgs() - val imageAsset: ImageAsset.PrivateAsset = ImageAsset.PrivateAsset( - mediaGalleryNavArgs.conversationId, - mediaGalleryNavArgs.messageId, - mediaGalleryNavArgs.isSelfAsset, - mediaGalleryNavArgs.isEphemeral - ) - - private val messageId = imageAsset.messageId - private val conversationId = imageAsset.conversationId - var mediaGalleryViewState by mutableStateOf( - MediaGalleryViewState( - isEphemeral = imageAsset.isEphemeral, - messageBottomSheetOptionsEnabled = mediaGalleryNavArgs.messageOptionsEnabled - ) - ) + + private val messageId = mediaGalleryNavArgs.messageId + private val conversationId = mediaGalleryNavArgs.conversationId + private val cellAssetId = mediaGalleryNavArgs.cellAssetId + + var mediaGalleryViewState by mutableStateOf(MediaGalleryViewState()) private set var deleteMessageDialogState: VisibilityState by mutableStateOf(VisibilityState()) @@ -88,13 +82,64 @@ class MediaGalleryViewModel @Inject constructor( init { getConversationTitle() + setupImageAsset() } - fun shareAsset(context: Context) { - viewModelScope.launch { + private fun setupImageAsset() = viewModelScope.launch { + if (cellAssetId == null) { + mediaGalleryViewState = mediaGalleryViewState.copy( + imageAsset = MediaGalleryImage.PrivateAsset( + asset = ImageAsset.PrivateAsset( + mediaGalleryNavArgs.conversationId, + mediaGalleryNavArgs.messageId, + mediaGalleryNavArgs.isSelfAsset, + mediaGalleryNavArgs.isEphemeral + ) + ) + ) + } else { + getAttachment(cellAssetId).onSuccess { attachment -> + (attachment as? CellAssetContent)?.let { cellAsset -> + val localPath = cellAsset.localPath + val url = cellAsset.contentUrl ?: cellAsset.previewUrl + + if (localPath != null) { + mediaGalleryViewState = mediaGalleryViewState.copy( + imageAsset = MediaGalleryImage.LocalAsset(localPath) + ) + } else if (url != null) { + mediaGalleryViewState = mediaGalleryViewState.copy( + imageAsset = MediaGalleryImage.UrlAsset( + url = url, + placeholder = cellAsset.previewUrl, + contentHash = cellAsset.contentHash, + ) + ) + } + } + } + } + } + + private fun shareAsset() = viewModelScope.launch { + if (cellAssetId == null) { assetDataPath(conversationId, messageId)?.run { - context.startFileShareIntent(first, second) + sendAction(MediaGalleryAction.Share(first, second)) } + } else { + getCellNode(cellAssetId) + .onSuccess { node -> + sendAction( + MediaGalleryAction.SharePublicLink( + assetId = node.uuid, + assetName = node.name ?: cellAssetId, + publicLinkId = node.publicLinkId + ) + ) + } + .onFailure { + sendAction(MediaGalleryAction.ShowError) + } } } @@ -161,6 +206,40 @@ class MediaGalleryViewModel @Inject constructor( onSnackbarMessage(MediaGallerySnackbarMessages.OnImageDownloadError) } + fun onMenuIntent(menuIntent: MenuIntent) { + when (menuIntent) { + is MenuIntent.React -> sendAction( + MediaGalleryAction.React( + messageId = messageId, + emoji = menuIntent.emoji + ) + ) + + MenuIntent.ShowDetails -> sendAction( + MediaGalleryAction.ShowDetails( + messageId = messageId, + isSelfAsset = mediaGalleryNavArgs.isSelfAsset + ) + ) + + MenuIntent.Reply -> sendAction( + MediaGalleryAction.Reply( + messageId = messageId + ) + ) + + MenuIntent.Download -> sendAction(MediaGalleryAction.Download) + + MenuIntent.Share -> shareAsset() + + MenuIntent.Delete -> { + deleteMessageDialogState.show( + DeleteMessageDialogState(mediaGalleryNavArgs.isSelfAsset, messageId, conversationId) + ) + } + } + } + fun deleteMessage(messageId: String, deleteForEveryone: Boolean) { viewModelScope.launch { deleteMessageDialogState.update { it.copy(loading = true) } @@ -168,9 +247,83 @@ class MediaGalleryViewModel @Inject constructor( .onFailure { onSnackbarMessage(MediaGallerySnackbarMessages.DeletingMessageError) }.onSuccess { - mediaGalleryViewState = mediaGalleryViewState.copy(messageDeleted = true) + sendAction(MediaGalleryAction.Close) } deleteMessageDialogState.dismiss() } } + + private fun buildMenuOptions() = buildList { + if (mediaGalleryNavArgs.messageOptionsEnabled) { + when { + cellAssetId != null -> { + add(MediaGalleryMenuItem.REACT) + add(MediaGalleryMenuItem.SHOW_DETAILS) + add(MediaGalleryMenuItem.REPLY) + add(MediaGalleryMenuItem.SHARE_PUBLIC_LINK) + } + + mediaGalleryNavArgs.isEphemeral -> { + add(MediaGalleryMenuItem.SHOW_DETAILS) + add(MediaGalleryMenuItem.DOWNLOAD) + add(MediaGalleryMenuItem.DELETE) + } + + else -> { + add(MediaGalleryMenuItem.REACT) + add(MediaGalleryMenuItem.SHOW_DETAILS) + add(MediaGalleryMenuItem.REPLY) + add(MediaGalleryMenuItem.DOWNLOAD) + add(MediaGalleryMenuItem.SHARE) + add(MediaGalleryMenuItem.DELETE) + } + } + } else if (cellAssetId == null) { + add(MediaGalleryMenuItem.DOWNLOAD) + if (!mediaGalleryNavArgs.isEphemeral) add(MediaGalleryMenuItem.SHARE) + add(MediaGalleryMenuItem.DELETE) + } + } + + fun onOptionsClick() { + mediaGalleryViewState = mediaGalleryViewState.copy( + menuItems = buildMenuOptions() + ) + } + + fun onOptionsDismissed() { + mediaGalleryViewState = mediaGalleryViewState.copy( + menuItems = emptyList() + ) + } +} + +sealed interface MediaGalleryAction { + data class ShowDetails(val messageId: String, val isSelfAsset: Boolean) : MediaGalleryAction + data class Share(val path: Path, val assetName: String) : MediaGalleryAction + data class React(val messageId: String, val emoji: String) : MediaGalleryAction + data class Reply(val messageId: String) : MediaGalleryAction + data object Download : MediaGalleryAction + data class SharePublicLink(val assetId: String, val assetName: String, val publicLinkId: String?) : MediaGalleryAction + data object ShowError : MediaGalleryAction + data object Close : MediaGalleryAction +} + +sealed interface MenuIntent { + data class React(val emoji: String) : MenuIntent + data object ShowDetails : MenuIntent + data object Reply : MenuIntent + data object Download : MenuIntent + data object Share : MenuIntent + data object Delete : MenuIntent +} + +enum class MediaGalleryMenuItem { + REACT, + SHOW_DETAILS, + REPLY, + DOWNLOAD, + SHARE, + SHARE_PUBLIC_LINK, + DELETE } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewState.kt index fa5c8740985..977157d6ee2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewState.kt @@ -18,9 +18,16 @@ package com.wire.android.ui.home.gallery +import com.wire.android.model.ImageAsset + data class MediaGalleryViewState( + val imageAsset: MediaGalleryImage? = null, val screenTitle: String? = null, - val isEphemeral: Boolean = false, - val messageBottomSheetOptionsEnabled: Boolean, - val messageDeleted: Boolean = false, + val menuItems: List = emptyList(), ) + +sealed interface MediaGalleryImage { + data class PrivateAsset(val asset: ImageAsset.PrivateAsset) : MediaGalleryImage + data class LocalAsset(val path: String) : MediaGalleryImage + data class UrlAsset(val url: String, val placeholder: String?, val contentHash: String?) : MediaGalleryImage +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/gallery/ZoomableImage.kt b/app/src/main/kotlin/com/wire/android/ui/home/gallery/ZoomableImage.kt index ffc298be4e2..08929654a85 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/gallery/ZoomableImage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/gallery/ZoomableImage.kt @@ -30,18 +30,44 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale -import com.wire.android.model.ImageAsset +import androidx.compose.ui.platform.LocalContext +import coil.compose.rememberAsyncImagePainter +import coil.request.CachePolicy +import coil.request.ImageRequest @Composable -fun ZoomableImage(imageAsset: ImageAsset.Remote, contentDescription: String, modifier: Modifier = Modifier) { +fun ZoomableImage(image: MediaGalleryImage, contentDescription: String, modifier: Modifier = Modifier) { var offsetX by remember { mutableStateOf(0f) } var offsetY by remember { mutableStateOf(0f) } var zoom by remember { mutableStateOf(1f) } val minScale = 1.0f val maxScale = 3f + val painter = when (image) { + is MediaGalleryImage.PrivateAsset -> image.asset.paint() + is MediaGalleryImage.LocalAsset -> rememberAsyncImagePainter(image.path) + is MediaGalleryImage.UrlAsset -> rememberAsyncImagePainter( + ImageRequest.Builder(LocalContext.current) + .data(image.url) + .diskCacheKey(image.contentHash) + .memoryCacheKey(image.contentHash) + .diskCachePolicy(CachePolicy.ENABLED) + .build(), + placeholder = image.placeholder?.let { + rememberAsyncImagePainter( + ImageRequest.Builder(LocalContext.current) + .diskCacheKey(image.contentHash) + .memoryCacheKey(image.contentHash) + .data(it) + .crossfade(true) + .build() + ) + } + ) + } + Image( - painter = imageAsset.paint(), + painter = painter, contentDescription = contentDescription, modifier = modifier .graphicsLayer( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageComposition.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageComposition.kt index cb3f73797fa..869fcfb7dd2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageComposition.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageComposition.kt @@ -32,6 +32,7 @@ data class MessageComposition( val quotedMessage: UIQuotedMessage.UIQuotedData? = null, val quotedMessageId: String? = null, val selectedMentions: List = emptyList(), + val isMultipart: Boolean = false, ) { fun getSelectedMentions(newMessageText: String): List { val result = mutableSetOf() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt index 4632530fbd6..c0b999af89a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt @@ -27,8 +27,8 @@ import com.wire.android.datastore.GlobalDataStore import com.wire.android.media.audiomessage.AudioFocusHelper import com.wire.android.media.audiomessage.AudioMediaPlayingState import com.wire.android.media.audiomessage.AudioState -import com.wire.android.media.audiomessage.AudioWavesMaskHelper import com.wire.android.media.audiomessage.RecordAudioMessagePlayer +import com.wire.android.media.audiomessage.toWavesMask import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.home.conversations.model.UriAsset import com.wire.android.util.CurrentScreen @@ -40,6 +40,7 @@ import com.wire.android.util.fromNioPathToContentUri import com.wire.android.util.getAudioLengthInMs import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.asset.KaliumFileSystem +import com.wire.kalium.logic.feature.asset.AudioNormalizedLoudnessBuilder import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.util.DateTimeUtil @@ -66,7 +67,7 @@ class RecordAudioViewModel @Inject constructor( private val currentScreenManager: CurrentScreenManager, private val audioMediaRecorder: AudioMediaRecorder, private val globalDataStore: GlobalDataStore, - private val audioWavesMaskHelper: AudioWavesMaskHelper, + private val audioNormalizedLoudnessBuilder: AudioNormalizedLoudnessBuilder, private val audioFocusHelper: AudioFocusHelper, private val dispatchers: DispatcherProvider, private val kaliumFileSystem: KaliumFileSystem @@ -219,7 +220,7 @@ class RecordAudioViewModel @Inject constructor( ).toInt() } ?: 0 ), - wavesMask = playableAudioFile?.let { audioWavesMaskHelper.getWaveMask(it) } ?: listOf() + wavesMask = playableAudioFile?.let { audioNormalizedLoudnessBuilder(it.path) }?.toWavesMask() ?: listOf() ) ) } @@ -284,6 +285,7 @@ class RecordAudioViewModel @Inject constructor( val outputFile = state.originalOutputFile val effectsFile = state.effectsOutputFile + val wavesMask = state.audioState.wavesMask state = state.copy( buttonState = RecordAudioButtonState.ENCODING, audioState = AudioState.DEFAULT, originalOutputFile = null, @@ -316,7 +318,7 @@ class RecordAudioViewModel @Inject constructor( } sendAction( RecordAudioViewActions.Recorded( - UriAsset( + uriAsset = UriAsset( uri = if (didSucceed) { context.fromNioPathToContentUri(nioPath = audioMediaRecorder.m4aOutputPath!!.toNioPath()) } else { @@ -331,7 +333,8 @@ class RecordAudioViewModel @Inject constructor( } else { "audio/wav" }, - saveToDeviceIfInvalid = false + saveToDeviceIfInvalid = false, + audioWavesMask = wavesMask ) ) ) @@ -386,7 +389,7 @@ class RecordAudioViewModel @Inject constructor( mimeType = SUPPORTED_AUDIO_MIME_TYPE ).toInt() ), - wavesMask = listOf() + wavesMask = state.audioState.wavesMask ), shouldApplyEffects = true ) @@ -407,7 +410,6 @@ class RecordAudioViewModel @Inject constructor( override fun onCleared() { super.onCleared() recordAudioMessagePlayer.close() - audioWavesMaskHelper.clear() } companion object { 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 a54c49ce42e..dcf870759ef 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 @@ -44,7 +44,7 @@ fun rememberMessageComposerStateHolder( draftMessageComposition: MessageComposition, onClearDraft: () -> Unit, onSaveDraft: (MessageDraft) -> Unit, - onMessageTextUpdate: (String) -> Unit, + onMessageTextUpdate: (MessageDraft) -> Unit, onSearchMentionQueryChanged: (String) -> Unit, onClearMentionSearchResult: () -> Unit, onTypingEvent: (Conversation.TypingIndicatorMode) -> Unit, @@ -140,9 +140,9 @@ class MessageComposerStateHolder( ) { val messageComposition = messageCompositionHolder.value.messageComposition - fun toEdit(messageId: String, editMessageText: String, mentions: List) { - messageCompositionHolder.value.setEditText(messageId, editMessageText, mentions) - messageCompositionInputStateHolder.toEdit(editMessageText) + fun toEdit(messageId: String, editMessageText: String, mentions: List, isMultipart: Boolean) { + messageCompositionHolder.value.setEditText(messageId, editMessageText, mentions, isMultipart) + messageCompositionInputStateHolder.toEdit(editMessageText, isMultipart) } fun toReply(message: UIMessage.Regular) { 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 051f0e23d42..c71c1c35216 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 @@ -58,7 +58,7 @@ class MessageCompositionHolder( var messageTextState: TextFieldState, val onClearDraft: () -> Unit, private val onSaveDraft: (MessageDraft) -> Unit, - private val onMessageTextUpdate: (String) -> Unit, + private val onMessageTextUpdate: (MessageDraft) -> Unit, private val onSearchMentionQueryChanged: (String) -> Unit, private val onClearMentionSearchResult: () -> Unit, private val onTypingEvent: (TypingIndicatorMode) -> Unit, @@ -120,7 +120,7 @@ class MessageCompositionHolder( updateTypingEvent(messageText.toString()) updateMentionsIfNeeded(messageText.toString()) requestMentionSuggestionIfNeeded(messageText.toString(), selection) - onMessageTextUpdate(messageText.toString()) + onMessageTextUpdate(messageComposition.value.toDraft(messageText = messageText.toString())) } } @@ -242,7 +242,7 @@ class MessageCompositionHolder( onSaveDraft(messageComposition.value.toDraft(resultText)) } - fun setEditText(messageId: String, editMessageText: String, mentions: List) { + fun setEditText(messageId: String, editMessageText: String, mentions: List, isMultipart: Boolean) { messageTextState.setTextAndPlaceCursorAtEnd(editMessageText) messageComposition.update { it.copy( @@ -250,6 +250,7 @@ class MessageCompositionHolder( editMessageId = messageId, quotedMessage = null, quotedMessageId = null, + isMultipart = isMultipart, ) } onSaveDraft(messageComposition.value.toDraft(editMessageText)) 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 787d2a9f325..67f6e813eeb 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 @@ -70,8 +70,11 @@ class MessageCompositionInputStateHolder( ) is CompositionState.Editing -> InputType.Editing( - isEditButtonEnabled = messageTextState.text != state.originalMessageText && - messageTextState.text.isNotMarkdownBlank() + isEditButtonEnabled = when { + messageTextState.text.isNotMarkdownBlank() && messageTextState.text != state.originalMessageText -> true + !messageTextState.text.isNotMarkdownBlank() && state.allowEmptyText -> true + else -> false + } ) } } @@ -104,8 +107,8 @@ class MessageCompositionInputStateHolder( optionsVisible = showOptions } - fun toEdit(editMessageText: String) { - compositionState = CompositionState.Editing(editMessageText) + fun toEdit(editMessageText: String, isMultipart: Boolean) { + compositionState = CompositionState.Editing(editMessageText, isMultipart) requestFocus() } @@ -211,7 +214,7 @@ class MessageCompositionInputStateHolder( private sealed class CompositionState { data object Composing : CompositionState() - data class Editing(val originalMessageText: String) : CompositionState() + data class Editing(val originalMessageText: String, val allowEmptyText: Boolean) : CompositionState() } sealed class InputType { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt index 3866cc9bb96..854d88c4ebd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt @@ -50,6 +50,7 @@ import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.typography +import com.wire.android.ui.common.upgradetoapps.UpgradeToGetAppsBanner import com.wire.android.ui.destinations.ChannelAccessOnCreateScreenDestination import com.wire.android.ui.destinations.ChannelHistoryScreenDestination import com.wire.android.ui.destinations.ConversationScreenDestination @@ -219,7 +220,7 @@ private fun GroupOptionState.GroupOptionsScreenMainContent( } } AllowGuestsOptions(groupMetadataState.isChannel, onAllowGuestChanged) - AllowServicesOptions(groupMetadataState.isChannel, onAllowServicesChanged) + AllowAppsOptions(onAllowServicesChanged) if (groupMetadataState.groupProtocol != CreateConversationParam.Protocol.MLS || mlsReadReceiptsEnabled) { ReadReceiptsOptions(groupMetadataState.isChannel, onReadReceiptChanged) } @@ -260,38 +261,6 @@ private fun GroupOptionState.ReadReceiptsOptions(isChannel: Boolean, onReadRecei ) } -@Composable -private fun GroupOptionState.AllowServicesOptions(isChannel: Boolean, onAllowServicesChanged: (Boolean) -> Unit) { - if (!isTeamAllowedToUseApps) return - - GroupConversationOptionsItem( - title = stringResource(R.string.allow_services), - switchState = SwitchState.Enabled( - value = isAllowAppsEnabled, - isOnOffVisible = false, - onCheckedChange = onAllowServicesChanged, - ), - arrowType = ArrowType.NONE, - clickable = Clickable(enabled = false, onClick = {}), - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface) - ) - - val description = if (isChannel) { - R.string.allow_services_channel_description - } else { - R.string.allow_services_regular_group_description - } - Text( - text = stringResource(description), - color = MaterialTheme.wireColorScheme.secondaryText, - modifier = Modifier.padding(MaterialTheme.wireDimensions.spacing16x), - textAlign = TextAlign.Left, - style = typography().body01, - ) -} - @Composable fun AccessOptions( accessType: ChannelAccessType, @@ -332,9 +301,48 @@ private fun GroupOptionState.AllowGuestsOptions(isChannel: Boolean, onAllowGuest modifier = Modifier.background(MaterialTheme.colorScheme.surface) ) - if (!isChannel) { + val description = if (isChannel) { + R.string.allow_guest_switch_channel_description + } else { + R.string.allow_guest_switch_description + } + Text( + text = stringResource(description), + color = MaterialTheme.wireColorScheme.secondaryText, + modifier = Modifier.padding(MaterialTheme.wireDimensions.spacing16x), + textAlign = TextAlign.Left, + style = typography().body01, + ) +} + +@Composable +private fun GroupOptionState.AllowAppsOptions(onAllowServicesChanged: (Boolean) -> Unit) { + GroupConversationOptionsItem( + title = stringResource(R.string.allow_services), + switchState = when (isTeamAllowedToUseApps) { + true -> SwitchState.Enabled( + value = isAllowAppsEnabled, + isOnOffVisible = false, + onCheckedChange = onAllowServicesChanged + ) + + false -> SwitchState.Disabled( + value = false, + isOnOffVisible = false, + ) + }, + arrowType = ArrowType.NONE, + clickable = Clickable(enabled = false, onClick = {}), + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + ) + + if (!isTeamAllowedToUseApps) { + UpgradeToGetAppsBanner() + } else { Text( - text = stringResource(R.string.allow_guest_switch_description), + text = stringResource(R.string.allow_apps_switch_description), color = MaterialTheme.wireColorScheme.secondaryText, modifier = Modifier.padding(MaterialTheme.wireDimensions.spacing16x), textAlign = TextAlign.Left, @@ -421,9 +429,10 @@ private fun PreviewGroupOptionScreen( groupMetadataState: GroupMetadataState, channelsHistoryOptionsEnabled: Boolean = BuildConfig.CHANNELS_HISTORY_OPTIONS_ENABLED, mlsReadReceiptsEnabled: Boolean = BuildConfig.MLS_READ_RECEIPTS_ENABLED, + withAppsEnabled: Boolean = true, ) = WireTheme { GroupOptionScreenContent( - groupOptionState = GroupOptionState(), + groupOptionState = GroupOptionState(isTeamAllowedToUseApps = withAppsEnabled), createGroupState = CreateGroupState.Default, groupMetadataState = groupMetadataState, onAccessClicked = {}, @@ -474,3 +483,47 @@ fun PreviewGroupOptionScreen_MlsGroupWithMlsReadReceiptsDisabled() = PreviewGrou ), mlsReadReceiptsEnabled = false, ) + +@Composable +@PreviewMultipleThemes +fun PreviewGroupOptionScreen_AppsDisabled_Group() = PreviewGroupOptionScreen( + groupMetadataState = GroupMetadataState( + isChannel = false, + groupProtocol = CreateConversationParam.Protocol.MLS, + ), + mlsReadReceiptsEnabled = false, + withAppsEnabled = false +) + +@Composable +@PreviewMultipleThemes +fun PreviewGroupOptionScreen_AppsEnabled_Group() = PreviewGroupOptionScreen( + groupMetadataState = GroupMetadataState( + isChannel = false, + groupProtocol = CreateConversationParam.Protocol.MLS, + ), + mlsReadReceiptsEnabled = false, + withAppsEnabled = false +) + +@Composable +@PreviewMultipleThemes +fun PreviewGroupOptionScreen_AppsDisabled_Channel() = PreviewGroupOptionScreen( + groupMetadataState = GroupMetadataState( + isChannel = true, + groupProtocol = CreateConversationParam.Protocol.MLS, + ), + mlsReadReceiptsEnabled = false, + withAppsEnabled = false +) + +@Composable +@PreviewMultipleThemes +fun PreviewGroupOptionScreen_AppsEnabled_Channel() = PreviewGroupOptionScreen( + groupMetadataState = GroupMetadataState( + isChannel = true, + groupProtocol = CreateConversationParam.Protocol.MLS, + ), + mlsReadReceiptsEnabled = false, + withAppsEnabled = true +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupsearch/NewGroupConversationSearchPeopleScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupsearch/NewGroupConversationSearchPeopleScreen.kt index 6d5484ad7e9..7dbdf2c9130 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupsearch/NewGroupConversationSearchPeopleScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupsearch/NewGroupConversationSearchPeopleScreen.kt @@ -28,7 +28,7 @@ import com.wire.android.navigation.annotation.app.WireDestination import com.wire.android.ui.destinations.NewGroupNameScreenDestination import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.home.conversations.search.SearchPeopleScreenType -import com.wire.android.ui.home.conversations.search.SearchUsersAndServicesScreen +import com.wire.android.ui.home.conversations.search.SearchUsersAndAppsScreen import com.wire.android.ui.home.newconversation.NewConversationViewModel import com.wire.android.ui.home.newconversation.common.NewConversationNavGraph import com.wire.kalium.logic.data.id.QualifiedID @@ -55,7 +55,7 @@ fun NewGroupConversationSearchPeopleScreen( } else { stringResource(id = R.string.label_new_group) } - SearchUsersAndServicesScreen( + SearchUsersAndAppsScreen( searchTitle = screenTitle, onOpenUserProfile = { contact -> OtherUserProfileScreenDestination(QualifiedID(contact.id, contact.domain)) @@ -67,9 +67,9 @@ fun NewGroupConversationSearchPeopleScreen( onContinue = { navigator.navigate(NavigationCommand(NewGroupNameScreenDestination)) }, isGroupSubmitVisible = newConversationViewModel.newGroupState.isGroupCreatingAllowed == true, onClose = onBackClicked, - onServiceClicked = { }, screenType = SearchPeopleScreenType.NEW_GROUP_CONVERSATION, selectedContacts = newConversationViewModel.newGroupState.selectedUsers, - isAppDiscoveryAllowed = false + onAppClicked = { }, + isAppsTabVisible = false ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/search/NewConversationSearchPeopleScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/search/NewConversationSearchPeopleScreen.kt index d2b59c613cc..5a2f10147e3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/search/NewConversationSearchPeopleScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/search/NewConversationSearchPeopleScreen.kt @@ -30,7 +30,7 @@ import com.wire.android.ui.NavGraphs import com.wire.android.ui.destinations.NewGroupConversationSearchPeopleScreenDestination import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.home.conversations.search.SearchPeopleScreenType -import com.wire.android.ui.home.conversations.search.SearchUsersAndServicesScreen +import com.wire.android.ui.home.conversations.search.SearchUsersAndAppsScreen import com.wire.android.ui.home.newconversation.NewConversationViewModel import com.wire.android.ui.home.newconversation.common.NewConversationNavGraph import com.wire.kalium.logic.data.id.QualifiedID @@ -47,7 +47,7 @@ fun NewConversationSearchPeopleScreen( val isSelfTeamMember = newConversationViewModel.newGroupState.isSelfTeamMember ?: false val shouldShowChannelPromotion = !isSelfTeamMember val showCreateTeamDialog = remember { mutableStateOf(false) } - SearchUsersAndServicesScreen( + SearchUsersAndAppsScreen( searchTitle = stringResource(id = R.string.label_new_conversation), shouldShowChannelPromotion = shouldShowChannelPromotion, isUserAllowedToCreateChannels = newConversationViewModel.isChannelCreationPossible, @@ -70,10 +70,10 @@ fun NewConversationSearchPeopleScreen( }, isGroupSubmitVisible = newConversationViewModel.newGroupState.isGroupCreatingAllowed == true, onClose = navigator::navigateBack, - onServiceClicked = { }, screenType = SearchPeopleScreenType.NEW_CONVERSATION, selectedContacts = newConversationViewModel.newGroupState.selectedUsers, - isAppDiscoveryAllowed = newConversationViewModel.groupOptionsState.isTeamAllowedToUseApps + isAppsTabVisible = false, + onAppClicked = { } ) if (showCreateTeamDialog.value) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/color/ChangeUserColorScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/color/ChangeUserColorScreen.kt index 1a6ca216139..7323047e7aa 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/color/ChangeUserColorScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/color/ChangeUserColorScreen.kt @@ -63,6 +63,7 @@ import com.wire.android.ui.home.conversations.mock.mockMessageWithText import com.wire.android.ui.home.conversations.mock.mockMessageWithTextContent import com.wire.android.ui.home.conversations.model.MessageFooter import com.wire.android.ui.home.conversations.model.MessageSource +import com.wire.android.ui.home.conversations.model.Reaction import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.WireTheme @@ -222,11 +223,10 @@ fun ChangeUserColorContent( ), messageFooter = MessageFooter( messageId = "messageId", - reactions = mapOf( - "👍" to 16, - "❤️" to 12, + reactionMap = mapOf( + "👍" to Reaction(16, isSelf = true), + "👎" to Reaction(16, isSelf = false), ), - ownReactions = setOf("👍"), ), ), conversationDetailsData = ConversationDetailsData.Group(null, QualifiedID("value", "domain")), diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt index 687ed85d98a..baebdc392c8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt @@ -96,7 +96,7 @@ fun MarkdownNodeBlockChildren( } ) - is MarkdownNode.Block.ThematicBreak -> MarkdownThematicBreak() + is MarkdownNode.Block.ThematicBreak -> MarkdownThematicBreak(nodeData.messageStyle) // Not used Blocks here is MarkdownNode.Block.TableContent.Body -> {} diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownThematicBreak.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownThematicBreak.kt index 23157389c2e..641084f8f33 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownThematicBreak.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownThematicBreak.kt @@ -21,11 +21,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.divider.WireDivider +import com.wire.android.ui.home.conversations.messages.item.MessageStyle import com.wire.android.ui.theme.wireColorScheme @Composable -fun MarkdownThematicBreak(modifier: Modifier = Modifier) { - WireDivider(modifier = modifier.padding(vertical = dimensions().spacing8x), color = MaterialTheme.wireColorScheme.outline) +fun MarkdownThematicBreak(messageStyle: MessageStyle, modifier: Modifier = Modifier) { + val color = when (messageStyle) { + MessageStyle.BUBBLE_SELF -> colorsScheme().selfBubble.onPrimary + MessageStyle.BUBBLE_OTHER -> colorsScheme().otherBubble.onPrimary + MessageStyle.NORMAL -> MaterialTheme.wireColorScheme.outline + } + WireDivider(modifier = modifier.padding(vertical = dimensions().spacing8x), color = color) } 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 09e3e5a9730..7d79af3d406 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 @@ -17,26 +17,15 @@ */ package com.wire.android.ui.userprofile.self -import androidx.compose.foundation.BorderStroke -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.material.Icon -import androidx.compose.material.Text -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.wire.android.R -import com.wire.android.ui.common.button.WireSecondaryButton +import com.wire.android.ui.common.card.WireOutlinedCard 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 @Composable @@ -44,57 +33,24 @@ fun CreateTeamInfoCard( onCreateAccount: () -> Unit, modifier: Modifier = Modifier ) { - OutlinedCard( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = colorsScheme().secondaryButtonSelected - ), - border = BorderStroke(dimensions().spacing1x, colorsScheme().secondaryButtonSelectedOutline), - ) { - Row( - modifier = Modifier.padding( - start = dimensions().spacing8x, - top = dimensions().spacing8x, - end = dimensions().spacing8x - ) - ) { + WireOutlinedCard( + title = stringResource(R.string.user_profile_create_team_card), + textContent = stringResource(R.string.user_profile_create_team_description_card), + mainActionButtonText = stringResource(R.string.user_profile_create_team_card_button), + onMainActionClick = onCreateAccount, + trailingIcon = { Icon( painter = painterResource(id = R.drawable.ic_info), contentDescription = null, tint = colorsScheme().onBackground ) - Text( - modifier = Modifier.padding(start = dimensions().spacing8x), - text = stringResource(R.string.user_profile_create_team_card), - style = MaterialTheme.wireTypography.label02, - color = colorsScheme().onBackground - ) - } - Text( - modifier = Modifier.padding( - start = dimensions().spacing8x, - top = dimensions().spacing4x, - end = dimensions().spacing8x - ), - text = stringResource(R.string.user_profile_create_team_description_card), - style = MaterialTheme.wireTypography.subline01, - color = colorsScheme().onBackground - ) - WireSecondaryButton( - modifier = Modifier - .padding(dimensions().spacing8x) - .height(dimensions().createTeamInfoCardButtonHeight), - text = stringResource(R.string.user_profile_create_team_card_button), - onClick = onCreateAccount, - fillMaxWidth = false, - minSize = dimensions().buttonSmallMinSize, - minClickableSize = dimensions().buttonMinClickableSize, - ) - } + }, + modifier = modifier + ) } @PreviewMultipleThemes @Composable fun PreviewCreateTeamInfoCard() = WireTheme { - CreateTeamInfoCard({ }) + CreateTeamInfoCard({ }) } diff --git a/app/src/main/kotlin/com/wire/android/util/FileManager.kt b/app/src/main/kotlin/com/wire/android/util/FileManager.kt index 0a482736924..46abf904a2e 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileManager.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileManager.kt @@ -116,6 +116,7 @@ class FileManager @Inject constructor(@ApplicationContext private val context: C attachmentUri: Uri, assetDestinationPath: Path, specifiedMimeType: String? = null, // specify a particular mimetype, otherwise it will be taken from the uri / file extension + audioWavesMask: List? = null, dispatcher: DispatcherProvider = DefaultDispatcherProvider(), ): AssetBundle? = withContext(dispatcher.io()) { try { @@ -135,7 +136,7 @@ class FileManager @Inject constructor(@ApplicationContext private val context: C // of video assets hitting the max limit. copyToPath(attachmentUri, assetDestinationPath) } - AssetBundle(assetKey, mimeType, assetDestinationPath, assetSize, assetFileName, attachmentType) + AssetBundle(assetKey, mimeType, assetDestinationPath, assetSize, assetFileName, attachmentType, audioWavesMask) } catch (e: IOException) { appLogger.e("There was an error while obtaining the file from disk", e) null diff --git a/app/src/main/res/drawable/ic_app.xml b/app/src/main/res/drawable/ic_app.xml new file mode 100644 index 00000000000..1c3c14a12e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_app.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index df9b257381b..ad4d89099f8 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -156,4 +156,5 @@ + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 474cd215dc5..0f56092318f 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -144,4 +144,5 @@ + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8c787ad3b56..b66b4806382 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -528,6 +528,8 @@ Gespeichert Datei nicht verfügbar Hochladen der Datei fehlgeschlagen + Datei ist nicht mehr verfügbar + Sie können diese Datei nicht herunterladen, da sie gelöscht wurde. Möchten Sie die Datei öffnen oder im Download-Ordner Ihres Geräts speichern? @@ -695,9 +697,9 @@ Bild ändern… Nachrichtendetails - Kopieren + Text kopieren Teilen - Bearbeiten + Text bearbeiten Löschen Nachricht kopiert Föderierte Benutzer @@ -795,6 +797,8 @@ Bitte seien Sie dennoch vorsichtig, mit wem Sie vertrauliche Informationen teilen. Überprüft (Proteus) Überprüft (Ende-zu-Ende-Identität) + Dateiverwaltung (Cells) aktiv + Selbstlöschende Nachrichten sind für Unterhaltungen mit Cells deaktiviert Sie haben 1 Person zur Unterhaltung hinzugefügt @@ -912,7 +916,6 @@ Personen suchen Personen oder Apps suchen Nach Personen anhand ihres Profilnamens oder @Benutzernamens suchen - Steigern Sie die Produktivität mit Apps. Mehr erfahren Keine Ergebnisse, bitte erneut versuchen. Ein Fehler ist aufgetreten @@ -993,6 +996,7 @@ Eingehender Gruppenanruf Audionachricht Unterhaltung wird gelöscht… + Dateien werden hochgeladen… Konstante Bitrate Mikrofon @@ -1094,7 +1098,6 @@ Öffnen Sie diese Unterhaltung für Personen außerhalb Ihres Teams. Diese Einstellung kann später jederzeit geändert werden. Apps zulassen Öffnen Sie diese Unterhaltung für Apps. Diese Einstellung kann später jederzeit geändert werden. - Öffnen Sie diese Unterhaltung für Personen außerhalb Ihres Teams oder für Apps. Diese Einstellung kann später jederzeit geändert werden. App Zur Unterhaltung hinzufügen Aus Unterhaltung entfernen @@ -1151,6 +1154,8 @@ Mindestens ein und höchstens 64 Zeichen Ihr Profilname wurde geändert Ihr Benutzername wurde geändert + Profilfarbe geändert + BEISPIEL Lesebestätigungen senden Wenn diese Option aktiviert ist, können andere sehen, ob Sie ihre Nachrichten lesen. @@ -1733,10 +1738,17 @@ registriert. Bitte versuchen Sie es mit einer anderen. Optionen Alle Dateien Dateien suchen + Dateiverwaltung (Cells Beta-Version) Ermöglichen Sie den Teilnehmern, ihre Dokumente und Mediendateien zu verwalten. Verwenden Sie Ordner, Tags und Filter, um effizienter zu arbeiten. Dieser Vorgang kann nicht rückgängig gemacht werden. Upload wiederholen Entfernen Filter anwenden Dateien filtern + Dateiverwaltung (Cells) Dauerhaft aktiv für diese Unterhaltung. + + %1$d Datei + %1$d Dateien + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5cfca51875f..aeae28077f6 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -467,9 +467,7 @@ Un mensaje eliminado no puede ser restaurado. Cambiar imagen... Detalles del mensaje - Copiar Compartir - Editar Eliminar Mensaje copiado aplicaciones @@ -741,7 +739,6 @@ Un mensaje eliminado no puede ser restaurado. Abra esta conversación a personas fuera de su equipo. Siempre puede cambiarlo más tarde. Permitir aplicaciones Abrir la conversación a aplicaciones. Siempre puedes cambiar esta opción más adelante. - Abrir la conversación a personas externas a tu equipo o a aplicaciones. Siempre puedes cambiar esta opción más adelante. Aplicación Añadir a la Conversación Remover de la Conversación @@ -1023,4 +1020,5 @@ Un mensaje eliminado no puede ser restaurado. + diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 4490f2dae4e..d522300f68e 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -204,9 +204,7 @@ Muuda pilti… Sõnumi üksikasjad - Kopeeri Jaga - Muuda Kustuta Sõnum kopeeritud @@ -445,4 +443,5 @@ + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5e8e5e7d30d..e07ad26aecd 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -481,9 +481,7 @@ Changez votre image de profil… Détails du Message - Copier Partager - Modifier Supprimer Vous n’avez pas utilisé cet appareil pendant un certain temps. Il est possible que certains messages n\'apparaissent pas ici. @@ -549,4 +547,5 @@ + diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 45b555b1d42..4f2aca9c15e 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -414,9 +414,7 @@ Promijeni sliku… Detalji o poruci - Kopiraj Podijeli - Uredi Izbriši Poruka kopirana @@ -561,4 +559,5 @@ + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 232d4398636..f1ab277eb44 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -678,9 +678,7 @@ Kép megváltoztatása… Üzenet részletei - Másolás Megosztás - Szerkesztés Törlés Üzenet másolva szövetséges felhasználók @@ -1656,4 +1654,5 @@ Kérjük, próbálja meg újra. Beállítások Minden fájl Fájlok keresése + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d35c3995534..07046cddf6c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -474,9 +474,7 @@ Un messaggio eliminato non può essere ripristinato. Cambia immagine... Dettagli messaggio - Copia Condividi - Modifica Elimina Messaggio copiato Sono presenti utenti federati, esterni e ospiti @@ -1236,4 +1234,5 @@ registrato. Sei pregato di riprovare. + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 84fea86d8a7..f3c7f77bb24 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -379,4 +379,5 @@ + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 825cf9ce5b2..65755f3b2a1 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -399,9 +399,7 @@ Usunięta wiadomość nie może zostać przywrócona.Zmień zdjęcie... Szczegóły wiadomości - Kopiuj Udostępnij - Edytuj Usuń Wiadomość skopiowana Obecni są użytkownicy federowani, zewnętrzni oraz goście. @@ -830,4 +828,5 @@ Prosimy użyć zarządzania zespołami (%1$s) na tym środow + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index b2130f1c3d2..9a3a36e694b 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -636,9 +636,7 @@ Uma mensagem excluída não pode ser restaurada. Mudar foto… Detalhes da mensagem - Copiar Compartilhar - Editar Excluir Mensagem copiada usuários federados @@ -1591,4 +1589,5 @@ registrado. Tente novamente. Canais estão disponíveis para os membros da equipe. Crie uma equipe e comece a colaborar gratuitamente! Criar Equipe Wire + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 26d5eca4462..0bcf04f10c3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -735,9 +735,9 @@ Изменить изображение… Сведения о сообщении - Скопировать + Скопировать текст Поделиться - Изменить + Изменить текст Удалить Сообщение скопировано федеративные пользователи @@ -850,7 +850,6 @@ Пожалуйста, будьте осторожны со всеми с кем делитесь конфиденциальной информацией. Верифицировано (Proteus) Верифицировано (сквозная идентификация) - Совместная работа с %1$s (Cells) Вы добавили 1 пользователя в беседу @@ -998,9 +997,7 @@ Поиск людей или приложений Wire Public Поиск пользователей по названию их профиля или @псевдониму - Повышайте производительность с помощью приложений. Подробнее - Как добавить приложения в вашу команду Результатов не найдено. Пожалуйста, попробуйте еще раз. Что-то пошло не так Контакты @@ -1083,6 +1080,7 @@ Нажмите, чтобы вернуться к вызову - Вызов... Аудиосообщение Удаление беседы… + Выгрузка файлов… Постоянный битрейт Микрофон @@ -1184,7 +1182,6 @@ Открыть эту беседу для пользователей не из вашей команды. Вы всегда сможете изменить это позже. Разрешить приложения Открыть эту беседу для приложений. Вы всегда сможете изменить это позже. - Откройте эту беседу для людей, не из вашей команды или приложений. Вы всегда сможете изменить это позже. Приложение Добавить в беседу Удалить из беседы @@ -1900,4 +1897,11 @@ Эта функция недоступна. Пожалуйста, свяжитесь со своим администратором. Совместная работа с файлами (Cells) Постоянно для этой беседы. + + %1$d файл + %1$d файла + %1$d файлов + %1$d файлов + + diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index 9dcd920f516..e582c1657c7 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -671,9 +671,7 @@ රූපය සංශෝධනය… පණිවිඩයේ විස්තර - පිටපතක් බෙදාගන්න - සංස්කරණය මකන්න පණිවිඩය පිටපත් විය ඒකාබද්ධ පරිශීලකයින් @@ -1667,4 +1665,5 @@ ගොනු සොයන්න උඩුගත කිරීම නැවත උත්සාහ කරන්න ඉවත් කරන්න + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 3e065614534..c45b33f41d1 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -346,9 +346,7 @@ Välj från galleri Ta en bild - Kopiera Dela - Redigera Radera gäster @@ -646,4 +644,5 @@ + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 6b25adf31c8..e8a083abe84 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -459,4 +459,5 @@ Yedek dosyayı korumak için güçlü bir şifre seçin. + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 9a9a6ba2079..6b9898182d8 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -219,4 +219,5 @@ + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index cbb8b9dd8c1..f32d629de29 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -112,4 +112,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f99c916c8a3..6fac25589a2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -624,8 +624,9 @@ OPTIONS PARTICIPANTS GROUP NAME - CONVERSATION ADMINS (%d) - CONVERSATION MEMBERS (%d) + ADMINS (%d) + MEMBERS (%d) + APPS (%d) This conversation has %s participants.\nUp to 500 people can join a conversation. %s participants Show all participants (%d) @@ -777,9 +778,9 @@ Change Picture… Message Details - Copy + Copy text Share - Edit + Edit text Delete Message copied federated users @@ -999,9 +1000,7 @@ Search people or apps Public Wire Search for people by their profile name or @username - Improve productivity with apps. Learn more - How to add apps to your team No results could be found. Please try again. Something went wrong People @@ -1187,9 +1186,10 @@ Change Allow guests Open this conversation to people outside your team. You can always change it later. + Open this conversation to people outside your team or to apps. You can always change it later. Allow apps Open this conversation to apps. You can always change it later. - Open this conversation to people outside your team or to apps. You can always change it later. + Open this conversation to apps. App Add To Conversation Remove From Conversation @@ -1893,4 +1893,21 @@ In group conversations, the group admin can overwrite this setting. %1$d file %1$d files + + + Your team doesn\'t use apps yet + To improve your workflow with apps, your team needs configuration. Please contact your team admin. + Apps + Go back to conversation details + adjust apps access + Your team hasn\'t added apps yet + Apps are helpers that can improve your workflow. As a team admin, you can add them in team management. + Apps are helpers that can improve your workflow. To use them, ask your team admin. + Apps are disabled in this conversation. Enable them in the conversation options. + + %1$s enabled **apps** for this conversation + %1$s disabled **apps** for this conversation + %1$s enabled **apps** for this conversation + %1$s disabled **apps** for this conversation + Added apps have access to the content of this conversation. diff --git a/app/src/test/kotlin/com/wire/android/media/audiomessage/AudioMessageViewModelTest.kt b/app/src/test/kotlin/com/wire/android/media/audiomessage/AudioMessageViewModelTest.kt index b26e47e408c..a68aeb71219 100644 --- a/app/src/test/kotlin/com/wire/android/media/audiomessage/AudioMessageViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/audiomessage/AudioMessageViewModelTest.kt @@ -90,7 +90,7 @@ class AudioMessageViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { - arrangement.audioMessagePlayer.fetchWavesMask(conversationId, messageId) + arrangement.audioMessagePlayer.getOrBuildWavesMask(conversationId, messageId) } } diff --git a/app/src/test/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelperTest.kt b/app/src/test/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelperTest.kt new file mode 100644 index 00000000000..1b4445cab07 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelperTest.kt @@ -0,0 +1,71 @@ +/* + * Wire + * Copyright (C) 2025 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.media.audiomessage + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AudioWavesMaskHelperTest { + @Test + fun givenWavesMask_whenMappedToNormalizedLoudness_thenTheResultIsCorrect() = runTest { + val wavesMask = listOf(0, 5, 50, 100, 150, 200, 250, 255) + val expectedNormalizedLoudness = byteArrayOf(0, 5, 50, 100, -106, -56, -6, -1) // UByteArray: [0, 5, 50, 100, 150, 200, 250, 255] + val normalizedLoudness = wavesMask.toNormalizedLoudness() + assert(expectedNormalizedLoudness.contentEquals(normalizedLoudness)) + } + + @Test + fun givenNormalizedLoudness_whenMappedToWavesMask_thenTheResultIsCorrect() = runTest { + val normalizedLoudness = byteArrayOf(0, 5, 50, 100, -106, -56, -6, -1) // UByteArray: [0, 5, 50, 100, 150, 200, 250, 255] + val expectedWavesMask = listOf(0, 5, 50, 100, 150, 200, 250, 255) + val wavesMask = normalizedLoudness.toWavesMask() + assertEquals(expectedWavesMask, wavesMask) + } + + @Test + fun givenWavesMask_whenMappedToNormalizedLoudnessAndBack_thenTheResultIsEqualToOriginal() = runTest { + val originalWavesMask = listOf(0, 5, 50, 100, 150, 200, 250, 255) + val resultWavesMask = originalWavesMask.toNormalizedLoudness().toWavesMask() + assertEquals(originalWavesMask, resultWavesMask) + } + + @Test + fun givenWavesMask_whenEqualizedStartingFrom0_thenTheResultIsCorrect() { + val wavesMask = listOf(0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100) + val expectedWavesMask = listOf(0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40) + val equalizedWavesMask = wavesMask.equalizedWavesMask(newMaxValue = 40, currentMaxValue = wavesMask.max(), startFrom1 = false) + assertEquals(expectedWavesMask, equalizedWavesMask) + } + + @Test + fun givenWavesMask_whenEqualizedStartingFrom1_thenTheResultIsCorrect() { + val wavesMask = listOf(0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100) + val expectedWavesMask = listOf(1, 5, 9, 13, 17, 21, 24, 28, 32, 36, 40) + val equalizedWavesMask = wavesMask.equalizedWavesMask(newMaxValue = 40, currentMaxValue = wavesMask.max(), startFrom1 = true) + assertEquals(expectedWavesMask, equalizedWavesMask) + } + + @Test + fun givenWavesMask_whenSampled_thenTheResultIsCorrect() { + val wavesMask = listOf(0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100) + val expectedWavesMask = listOf(0, 25, 50, 75, 100) + val sampledWavesMask = wavesMask.sampledWavesMask(amount = 5) + assertEquals(expectedWavesMask, sampledWavesMask) + } +} diff --git a/app/src/test/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayerTest.kt b/app/src/test/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayerTest.kt index fde0de9c08f..d4f0fbef151 100644 --- a/app/src/test/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayerTest.kt @@ -23,15 +23,23 @@ import android.media.PlaybackParams import app.cash.turbine.TurbineTestContext import app.cash.turbine.test import com.wire.android.config.TestDispatcherProvider +import com.wire.android.framework.TestMessage +import com.wire.android.framework.TestMessage.DUMMY_ASSET_LOCAL_DATA +import com.wire.android.framework.TestMessage.DUMMY_ASSET_REMOTE_DATA import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer.MessageIdWrapper import com.wire.android.services.ServicesManager import com.wire.kalium.common.error.NetworkFailure 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.data.message.AssetContent +import com.wire.kalium.logic.data.message.Message +import com.wire.kalium.logic.data.message.MessageContent import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.asset.AudioNormalizedLoudnessBuilder import com.wire.kalium.logic.feature.asset.GetMessageAssetUseCase import com.wire.kalium.logic.feature.asset.MessageAssetResult +import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -43,7 +51,6 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import okio.Path import okio.Path.Companion.toOkioPath import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -60,16 +67,16 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessfulAssetFetch_whenPlayingAudioForFirstTime_thenEmitStatesAsExpected() = runTest(dispatcher) { + val testAudioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val messageIdWrapper = MessageIdWrapper(conversationId, testAudioMessageId) val (arrangement, conversationAudioMessagePlayer) = Arrangement(tempDir) .withAudioMediaPlayerReturningTotalTime(1000) .withSuccessfulAssetFetch() .withCurrentSession() + .withGetMessageByIdReturningSuccess(testAudioMessageId, conversationId) .arrange() - val testAudioMessageId = "some-dummy-message-id" - val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") - val messageIdWrapper = MessageIdWrapper(conversationId, testAudioMessageId) - conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() @@ -91,7 +98,7 @@ class ConversationAudioMessagePlayerTest { awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] assert(currentState != null) - assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + assertEquals(Arrangement.WAVES_MASK, currentState!!.wavesMask) } awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] @@ -124,17 +131,17 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessfulAssetFetch_whenPlayingTheSameMessageIdTwiceSequentially_thenEmitStatesAsExpected() = runTest(dispatcher) { + val testAudioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val messageIdWrapper = MessageIdWrapper(conversationId, testAudioMessageId) val (arrangement, conversationAudioMessagePlayer) = Arrangement(tempDir) .withSuccessfulAssetFetch() .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) .withMediaPlayerPlaying() + .withGetMessageByIdReturningSuccess(testAudioMessageId, conversationId) .arrange() - val testAudioMessageId = "some-dummy-message-id" - val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") - val messageIdWrapper = MessageIdWrapper(conversationId, testAudioMessageId) - conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() @@ -157,7 +164,7 @@ class ConversationAudioMessagePlayerTest { awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] assert(currentState != null) - assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + assertEquals(Arrangement.WAVES_MASK, currentState!!.wavesMask) } awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] @@ -201,17 +208,18 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessfulAssetFetch_whenPlayingDifferentAudioAfterFirstOneIsPlayed_thenEmitStatesAsExpected() = runTest(dispatcher) { - val (arrangement, conversationAudioMessagePlayer) = Arrangement(tempDir) - .withSuccessfulAssetFetch() - .withCurrentSession() - .withAudioMediaPlayerReturningTotalTime(1000) - .arrange() - val firstAudioMessageId = "some-dummy-message-id1" val secondAudioMessageId = "some-dummy-message-id2" val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") val firstAudioMessageIdWrapper = MessageIdWrapper(conversationId, firstAudioMessageId) val secondAudioMessageIdWrapper = MessageIdWrapper(conversationId, secondAudioMessageId) + val (arrangement, conversationAudioMessagePlayer) = Arrangement(tempDir) + .withSuccessfulAssetFetch() + .withCurrentSession() + .withAudioMediaPlayerReturningTotalTime(1000) + .withGetMessageByIdReturningSuccess(firstAudioMessageId, conversationId) + .withGetMessageByIdReturningSuccess(secondAudioMessageId, conversationId) + .arrange() conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart @@ -235,7 +243,7 @@ class ConversationAudioMessagePlayerTest { awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) - assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + assertEquals(Arrangement.WAVES_MASK, currentState!!.wavesMask) } awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] @@ -281,7 +289,7 @@ class ConversationAudioMessagePlayerTest { awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) - assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + assertEquals(Arrangement.WAVES_MASK, currentState!!.wavesMask) } awaitAndAssertStateUpdate { state -> val currentState = state[secondAudioMessageIdWrapper] @@ -304,17 +312,18 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessfulAssetFetch_whenPlayingDifferentAudioAfterFirstOneIsPlayedAndSecondResumed_thenEmitStatesAsExpected() = runTest(dispatcher) { - val (arrangement, conversationAudioMessagePlayer) = Arrangement(tempDir) - .withSuccessfulAssetFetch() - .withCurrentSession() - .withAudioMediaPlayerReturningTotalTime(1000) - .arrange() - val firstAudioMessageId = "some-dummy-message-id1" val secondAudioMessageId = "some-dummy-message-id2" val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") val firstAudioMessageIdWrapper = MessageIdWrapper(conversationId, firstAudioMessageId) val secondAudioMessageIdWrapper = MessageIdWrapper(conversationId, secondAudioMessageId) + val (arrangement, conversationAudioMessagePlayer) = Arrangement(tempDir) + .withSuccessfulAssetFetch() + .withCurrentSession() + .withAudioMediaPlayerReturningTotalTime(1000) + .withGetMessageByIdReturningSuccess(firstAudioMessageId, conversationId) + .withGetMessageByIdReturningSuccess(secondAudioMessageId, conversationId) + .arrange() conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart @@ -338,7 +347,7 @@ class ConversationAudioMessagePlayerTest { awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) - assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + assertEquals(Arrangement.WAVES_MASK, currentState!!.wavesMask) } awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] @@ -386,7 +395,7 @@ class ConversationAudioMessagePlayerTest { awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) - assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + assertEquals(Arrangement.WAVES_MASK, currentState!!.wavesMask) } awaitAndAssertStateUpdate { state -> val currentState = state[secondAudioMessageIdWrapper] @@ -431,7 +440,7 @@ class ConversationAudioMessagePlayerTest { awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] assert(currentState != null) - assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + assertEquals(Arrangement.WAVES_MASK, currentState!!.wavesMask) } awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageIdWrapper] @@ -461,17 +470,17 @@ class ConversationAudioMessagePlayerTest { @Test fun givenTheSuccessfulAssetFetch_whenPlayingDifferentAudioAfterFirstOneIsPlayedAndSecondStoppedAndResume_thenEmitStatesAsExpected() = runTest(dispatcher) { + val testAudioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + val messageIdWrapper = MessageIdWrapper(conversationId, testAudioMessageId) val (arrangement, conversationAudioMessagePlayer) = Arrangement(tempDir) .withSuccessfulAssetFetch() .withCurrentSession() .withAudioMediaPlayerReturningTotalTime(1000) .withMediaPlayerPlaying() + .withGetMessageByIdReturningSuccess(testAudioMessageId, conversationId) .arrange() - val testAudioMessageId = "some-dummy-message-id" - val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") - val messageIdWrapper = MessageIdWrapper(conversationId, testAudioMessageId) - conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() @@ -494,7 +503,7 @@ class ConversationAudioMessagePlayerTest { awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] assert(currentState != null) - assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + assertEquals(Arrangement.WAVES_MASK, currentState!!.wavesMask) } awaitAndAssertStateUpdate { state -> val currentState = state[messageIdWrapper] @@ -566,15 +575,15 @@ class ConversationAudioMessagePlayerTest { @Test fun givenPlayingAudioMessage_whenStopAudioCalled_thenServiceStoppedAndAudioFocusAbandoned() = runTest(dispatcher) { + val testAudioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") val (arrangement, conversationAudioMessagePlayer) = Arrangement(tempDir) .withAudioMediaPlayerReturningTotalTime(1000) .withSuccessfulAssetFetch() .withCurrentSession() + .withGetMessageByIdReturningSuccess(testAudioMessageId, conversationId) .arrange() - val testAudioMessageId = "some-dummy-message-id" - val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") - conversationAudioMessagePlayer.observableAudioMessagesState.test { // skip first emit from onStart awaitItem() @@ -596,15 +605,15 @@ class ConversationAudioMessagePlayerTest { @Test fun givenCachedSuccessfulAudioMessageFetchWithExistingFile_whenPlayingAgain_thenReuseTheSameAssetResult() = runTest(dispatcher) { + val audioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") val (arrangement, conversationAudioMessagePlayer) = Arrangement(tempDir) .withAudioMediaPlayerReturningTotalTime(1000) .withSuccessfulAssetFetch(fileExists = true) .withCurrentSession() + .withGetMessageByIdReturningSuccess(audioMessageId, conversationId) .arrange() - val audioMessageId = "some-dummy-message-id" - val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") - conversationAudioMessagePlayer.playAudio(conversationId, audioMessageId) // play the first time conversationAudioMessagePlayer.forceToStopCurrentAudioMessage() // mock the completion of the audio media player conversationAudioMessagePlayer.playAudio(conversationId, audioMessageId) // play the second time @@ -618,15 +627,15 @@ class ConversationAudioMessagePlayerTest { @Test fun givenCachedSuccessfulAudioMessageFetchWithNonExistingFile_whenPlayingAgain_thenGetAssetAgain() = runTest(dispatcher) { + val audioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") val (arrangement, conversationAudioMessagePlayer) = Arrangement(tempDir) .withAudioMediaPlayerReturningTotalTime(1000) .withSuccessfulAssetFetch() .withCurrentSession() + .withGetMessageByIdReturningSuccess(audioMessageId, conversationId) .arrange() - val audioMessageId = "some-dummy-message-id" - val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") - arrangement.withSuccessfulAssetFetch(fileExists = true) // first time the file exists conversationAudioMessagePlayer.playAudio(conversationId, audioMessageId) // play the first time conversationAudioMessagePlayer.forceToStopCurrentAudioMessage() // mock the completion of the audio media player @@ -642,15 +651,15 @@ class ConversationAudioMessagePlayerTest { @Test fun givenCachedFailedAudioMessageFetch_whenPlayingAgain_thenGetAssetAgain() = runTest(dispatcher) { + val audioMessageId = "some-dummy-message-id" + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") val (arrangement, conversationAudioMessagePlayer) = Arrangement(tempDir) .withAudioMediaPlayerReturningTotalTime(1000) .withFailedAssetFetch() .withCurrentSession() + .withGetMessageByIdReturningSuccess(audioMessageId, conversationId) .arrange() - val audioMessageId = "some-dummy-message-id" - val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") - conversationAudioMessagePlayer.playAudio(conversationId, audioMessageId) // play the first time conversationAudioMessagePlayer.forceToStopCurrentAudioMessage() // mock the completion of the audio media player conversationAudioMessagePlayer.playAudio(conversationId, audioMessageId) // play the second time @@ -682,7 +691,7 @@ class Arrangement(private val tempDir: File) { lateinit var mediaPlayer: MediaPlayer @MockK - lateinit var wavesMaskHelper: AudioWavesMaskHelper + lateinit var audioNormalizedLoudnessBuilder: AudioNormalizedLoudnessBuilder @MockK lateinit var servicesManager: ServicesManager @@ -693,13 +702,16 @@ class Arrangement(private val tempDir: File) { @MockK lateinit var getAssetMessage: GetMessageAssetUseCase + @MockK + lateinit var getMessageById: GetMessageByIdUseCase + private val testScope: CoroutineScope = CoroutineScope(dispatcher) private val conversationAudioMessagePlayer by lazy { ConversationAudioMessagePlayer( context = context, audioMediaPlayer = mediaPlayer, - wavesMaskHelper = wavesMaskHelper, + audioNormalizedLoudnessBuilder = audioNormalizedLoudnessBuilder, servicesManager = { servicesManager }, audioFocusHelper = audioFocusHelper, coreLogic = coreLogic, @@ -712,8 +724,8 @@ class Arrangement(private val tempDir: File) { MockKAnnotations.init(this, relaxed = true) every { coreLogic.getSessionScope(any()).messages.getAssetMessage } returns getAssetMessage - every { wavesMaskHelper.getWaveMask(any()) } returns WAVES_MASK - every { wavesMaskHelper.clear() } returns Unit + every { coreLogic.getSessionScope(any()).messages.getMessageById } returns getMessageById + coEvery { audioNormalizedLoudnessBuilder(any()) } returns WAVES_MASK.toNormalizedLoudness() every { mediaPlayer.currentPosition } returns 100 every { servicesManager.stopPlayingAudioMessageService() } returns Unit @@ -772,9 +784,31 @@ class Arrangement(private val tempDir: File) { every { mediaPlayer.playbackParams } returns params } + fun withGetMessageByIdReturningSuccess( + messageId: String, + conversationId: ConversationId, + message: Message = AUDIO_MESSAGE.copy(id = messageId, conversationId = conversationId), + ) = apply { + coEvery { + getMessageById(any(), any()) + } returns GetMessageByIdUseCase.Result.Success(message) + } + fun arrange() = this to conversationAudioMessagePlayer companion object { val WAVES_MASK = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 0) + val AUDIO_MESSAGE = TestMessage.ASSET_MESSAGE.copy( + content = MessageContent.Asset( + AssetContent( + 0L, + "name", + "audio/wav", + AssetContent.AssetMetadata.Audio(10000L, WAVES_MASK.toNormalizedLoudness()), + DUMMY_ASSET_REMOTE_DATA, + DUMMY_ASSET_LOCAL_DATA, + ) + ) + ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupDetailsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupDetailsViewModelTest.kt index dbcf44b4c74..05ddba6f62a 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupDetailsViewModelTest.kt @@ -23,8 +23,6 @@ import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider -import com.wire.android.framework.TestConversation -import com.wire.android.framework.TestConversationDetails import com.wire.android.framework.TestUser import com.wire.android.mapper.testUIParticipant import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsState @@ -50,7 +48,6 @@ import com.wire.kalium.logic.feature.conversation.ArchiveStatusUpdateResult import com.wire.kalium.logic.feature.conversation.ConversationUpdateReceiptModeResult import com.wire.kalium.logic.feature.conversation.ConversationUpdateStatusResult import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase -import com.wire.kalium.logic.feature.conversation.UpdateConversationAccessRoleUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationArchivedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationMutedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationReceiptModeUseCase @@ -69,7 +66,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant import org.junit.jupiter.api.Assertions.assertEquals @@ -172,7 +168,7 @@ class GroupDetailsViewModelTest { details.conversation.teamId?.value == selfTeam.id, viewModel.groupOptionsState.value.isUpdatingGuestAllowed ) - assertEquals(true, viewModel.groupOptionsState.value.isUpdatingServicesAllowed) + assertEquals(true, viewModel.groupOptionsState.value.isUpdatingAppsAllowed) assertEquals(true, viewModel.groupOptionsState.value.isUpdatingSelfDeletingAllowed) assertEquals( details.conversation.isTeamGroup(), @@ -180,121 +176,6 @@ class GroupDetailsViewModelTest { ) } - @Test - fun `when disabling Services , then the dialog must state must be updated`() = runTest { - // Given - val members = buildList { - for (i in 1..5) { - add(testUIParticipant(i)) - } - } - val conversationParticipantsData = ConversationParticipantsData( - participants = members, - allParticipantsCount = members.size - ) - - val details = testGroup - - val (_, viewModel) = GroupConversationDetailsViewModelArrangement() - .withUpdateConversationAccessUseCaseReturns( - UpdateConversationAccessRoleUseCase.Result.Success - ).withConversationDetailUpdate(details) - .withConversationMembersUpdate(conversationParticipantsData) - .arrange() - - viewModel.onServicesUpdate(false) - assertEquals(true, viewModel.groupOptionsState.value.changeServiceOptionConfirmationRequired) - } - - @Test - fun `when no guests allowed and enabling services, use case is called with the correct values`() = runTest { - // Given - val members = buildList { - for (i in 1..5) { - add(testUIParticipant(i)) - } - } - val conversationParticipantsData = ConversationParticipantsData( - participants = members, - allParticipantsCount = members.size, - ) - - val details = testGroup.copy( - conversation = testGroup.conversation.copy( - accessRole = Conversation.defaultGroupAccessRoles.toMutableList().apply { - remove(Conversation.AccessRole.NON_TEAM_MEMBER) - remove(Conversation.AccessRole.GUEST) - }, - access = listOf() - ) - ) - - val (arrangement, viewModel) = GroupConversationDetailsViewModelArrangement() - .withUpdateConversationAccessUseCaseReturns( - UpdateConversationAccessRoleUseCase.Result.Success - ).withConversationDetailUpdate(details) - .withConversationMembersUpdate(conversationParticipantsData) - .arrange() - - viewModel.onServicesUpdate(true) - coVerify(exactly = 1) { - arrangement.updateConversationAccessRoleUseCase( - conversationId = details.conversation.id, - accessRoles = Conversation - .defaultGroupAccessRoles - .toMutableSet() - .apply { - add(Conversation.AccessRole.SERVICE) - remove(Conversation.AccessRole.NON_TEAM_MEMBER) - }, - access = Conversation.defaultGroupAccess - ) - } - } - - @Test - fun `when no guests allowed and disable service dialog confirmed, then use case is called with the correct values`() = runTest { - // Given - val members = buildList { - for (i in 1..5) { - add(testUIParticipant(i)) - } - } - val conversationParticipantsData = ConversationParticipantsData( - participants = members, - allParticipantsCount = members.size - ) - - val details = testGroup.copy( - conversation = testGroup.conversation.copy( - accessRole = Conversation.defaultGroupAccessRoles.toMutableList().apply { - remove(Conversation.AccessRole.NON_TEAM_MEMBER) - remove(Conversation.AccessRole.GUEST) - }, - access = listOf() - ) - ) - - val (arrangement, viewModel) = GroupConversationDetailsViewModelArrangement() - .withUpdateConversationAccessUseCaseReturns( - UpdateConversationAccessRoleUseCase.Result.Success - ).withConversationDetailUpdate(details) - .withConversationMembersUpdate(conversationParticipantsData) - .arrange() - - viewModel.onServiceDialogConfirm() - assertEquals(false, viewModel.groupOptionsState.value.changeServiceOptionConfirmationRequired) - coVerify(exactly = 1) { - arrangement.updateConversationAccessRoleUseCase( - conversationId = details.conversation.id, - accessRoles = Conversation.defaultGroupAccessRoles.toMutableSet().apply { - remove(Conversation.AccessRole.NON_TEAM_MEMBER) - }, - access = Conversation.defaultGroupAccess - ) - } - } - @Test fun `given a group conversation, when self is admin and in owner team, then should be able to edit Guests option`() = runTest { // Given @@ -453,18 +334,6 @@ class GroupDetailsViewModelTest { assertEquals(false, it.isUpdatingGuestAllowed) } - @Test - fun `given user is admin, when init group options, then services update is allowed`() = - testUpdatingAllowedFields(isSelfAnAdmin = true) { - assertEquals(true, it.isUpdatingServicesAllowed) - } - - @Test - fun `given user is not admin, when init group options, then services update is not allowed`() = - testUpdatingAllowedFields(isSelfAnAdmin = false) { - assertEquals(false, it.isUpdatingServicesAllowed) - } - @Test fun `given user is admin, when init group options, then self deleting update is allowed`() = testUpdatingAllowedFields(isSelfAnAdmin = true) { @@ -674,44 +543,6 @@ class GroupDetailsViewModelTest { assertEquals(true, result) } - @Test - fun `given isServicesAllowed for team, then state should reflect this to allow`() = runTest { - // given - val conversation = - TestConversation.GROUP().copy( - accessRole = listOf(Conversation.AccessRole.SERVICE) - ) - - val (_, viewModel) = GroupConversationDetailsViewModelArrangement() - .withAppsAllowedResult(true) - .withConversationDetailUpdate(TestConversationDetails.GROUP.copy(conversation = conversation)) - .arrange() - advanceUntilIdle() - - // when - // then - assertEquals(true, viewModel.groupOptionsState.value.isServicesAllowed) - } - - @Test - fun `given isServicesAllowed for team, but no role for conversation, then state should reflect this to not allow`() = runTest { - // given - val conversation = - TestConversation.GROUP().copy( - accessRole = listOf(Conversation.AccessRole.GUEST) - ) - - val (_, viewModel) = GroupConversationDetailsViewModelArrangement() - .withAppsAllowedResult(true) - .withConversationDetailUpdate(TestConversationDetails.GROUP.copy(conversation = conversation)) - .arrange() - advanceUntilIdle() - - // when - // then - assertEquals(false, viewModel.groupOptionsState.value.isServicesAllowed) - } - companion object { val dummyConversationId = ConversationId("some-dummy-value", "some.dummy.domain") val testGroup = ConversationDetails.Group.Regular( @@ -760,9 +591,6 @@ internal class GroupConversationDetailsViewModelArrangement { @MockK lateinit var observeParticipantsForConversationUseCase: ObserveParticipantsForConversationUseCase - @MockK - lateinit var updateConversationAccessRoleUseCase: UpdateConversationAccessRoleUseCase - @MockK lateinit var getSelfTeamUseCase: GetUpdatedSelfTeamUseCase @@ -801,14 +629,12 @@ internal class GroupConversationDetailsViewModelArrangement { getSelfUser = getSelfUser, observeConversationDetails = observeConversationDetails, observeConversationMembers = observeParticipantsForConversationUseCase, - updateConversationAccessRole = updateConversationAccessRoleUseCase, getSelfTeam = getSelfTeamUseCase, savedStateHandle = savedStateHandle, updateConversationReceiptMode = updateConversationReceiptMode, isMLSEnabled = isMLSEnabledUseCase, observeSelfDeletionTimerSettingsForConversation = observeSelfDeletionTimerSettingsForConversation, refreshUsersWithoutMetadata = refreshUsersWithoutMetadata, - observeIsAppsAllowedForUsage = observeIsAppsAllowedForUsage, isWireCellsEnabled = isWireCellsEnabled, ) } @@ -860,10 +686,6 @@ internal class GroupConversationDetailsViewModelArrangement { observeParticipantsForConversationFlow.emit(conversationParticipantsData) } - suspend fun withUpdateConversationAccessUseCaseReturns(result: UpdateConversationAccessRoleUseCase.Result) = apply { - coEvery { updateConversationAccessRoleUseCase(any(), any(), any()) } returns result - } - suspend fun withSelfTeamUseCaseReturns(result: Team?) = apply { coEvery { getSelfTeamUseCase() } returns Either.Right(result) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModelTest.kt new file mode 100644 index 00000000000..534272020ae --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModelTest.kt @@ -0,0 +1,415 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.details.updateappsaccess + +import androidx.lifecycle.SavedStateHandle +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.NavigationTestExtension +import com.wire.android.config.TestDispatcherProvider +import com.wire.android.framework.TestConversation +import com.wire.android.framework.TestConversationDetails +import com.wire.android.framework.TestUser +import com.wire.android.ui.home.conversations.details.participants.model.ConversationParticipantsData +import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase +import com.wire.android.ui.navArgs +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.conversation.ConversationDetails +import com.wire.kalium.logic.data.conversation.MutedConversationStatus +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.TeamId +import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase +import com.wire.kalium.logic.feature.conversation.UpdateConversationAccessRoleUseCase +import com.wire.kalium.logic.feature.conversation.apps.ChangeAccessForAppsInConversationUseCase +import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppsAllowedForUsageUseCase +import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) +class UpdateAppsAccessViewModelTest { + + @Test + fun `when disabling apps access, then the dialog state must be updated`() = runTest { + // Given + val conversationParticipantsData = ConversationParticipantsData(isSelfAnAdmin = true) + val details = testGroup + + val (_, viewModel) = UpdateAppsAccessViewModelArrangement() + .withUpdateConversationAccessUseCaseReturns( + UpdateConversationAccessRoleUseCase.Result.Success + ) + .withConversationDetailUpdate(details) + .withConversationMembersUpdate(conversationParticipantsData) + .withAppsAllowedResult(true) + .arrange() + + // When + viewModel.onAppsAccessUpdate(false) + + // Then + assertEquals(true, viewModel.updateAppsAccessState.shouldShowDisableAppsConfirmationDialog) + } + + @Test + fun `when no guests allowed and enabling apps access, use case is called with the correct values`() = runTest { + // Given + val conversationParticipantsData = ConversationParticipantsData(isSelfAnAdmin = true) + val details = testGroup.copy( + conversation = testGroup.conversation.copy( + accessRole = Conversation.defaultGroupAccessRoles.toMutableList().apply { + remove(Conversation.AccessRole.NON_TEAM_MEMBER) + remove(Conversation.AccessRole.GUEST) + }, + access = listOf() + ) + ) + + val (arrangement, viewModel) = UpdateAppsAccessViewModelArrangement() + .withGuestDisabledNavArgs() + .withUpdateConversationAccessUseCaseReturns( + UpdateConversationAccessRoleUseCase.Result.Success + ) + .withConversationDetailUpdate(details) + .withConversationMembersUpdate(conversationParticipantsData) + .withAppsAllowedResult(true) + .arrange() + + // When + viewModel.onAppsAccessUpdate(true) + + // Then + coVerify(exactly = 1) { + arrangement.changeAccessForAppsInConversationUseCase( + conversationId = details.conversation.id, + accessRoles = Conversation + .defaultGroupAccessRoles + .toMutableSet() + .apply { + add(Conversation.AccessRole.SERVICE) + remove(Conversation.AccessRole.NON_TEAM_MEMBER) + }, + access = Conversation.defaultGroupAccess + ) + } + } + + @Test + fun `when no guests allowed and disable apps access dialog confirmed, then use case is called with the correct values`() = runTest { + // Given + val conversationParticipantsData = ConversationParticipantsData(isSelfAnAdmin = true) + val details = testGroup.copy( + conversation = testGroup.conversation.copy( + accessRole = Conversation.defaultGroupAccessRoles.toMutableList().apply { + remove(Conversation.AccessRole.NON_TEAM_MEMBER) + remove(Conversation.AccessRole.GUEST) + }, + access = listOf() + ) + ) + + val (arrangement, viewModel) = UpdateAppsAccessViewModelArrangement() + .withGuestDisabledNavArgs() + .withUpdateConversationAccessUseCaseReturns( + UpdateConversationAccessRoleUseCase.Result.Success + ) + .withConversationDetailUpdate(details) + .withConversationMembersUpdate(conversationParticipantsData) + .withAppsAllowedResult(true) + .arrange() + + // When + viewModel.onServiceDialogConfirm() + + // Then + assertEquals(false, viewModel.updateAppsAccessState.shouldShowDisableAppsConfirmationDialog) + coVerify(exactly = 1) { + arrangement.changeAccessForAppsInConversationUseCase( + conversationId = details.conversation.id, + accessRoles = Conversation.defaultGroupAccessRoles.toMutableSet().apply { + remove(Conversation.AccessRole.NON_TEAM_MEMBER) + }, + access = Conversation.defaultGroupAccess + ) + } + } + + @Test + fun `given user is admin, when observing conversation, then apps access update is allowed`() = runTest { + // Given + val conversationParticipantsData = ConversationParticipantsData(isSelfAnAdmin = true) + val details = testGroup + + val (_, viewModel) = UpdateAppsAccessViewModelArrangement() + .withConversationDetailUpdate(details) + .withConversationMembersUpdate(conversationParticipantsData) + .withAppsAllowedResult(true) + .arrange() + + advanceUntilIdle() + + // Then + assertEquals(true, viewModel.updateAppsAccessState.isUpdatingAppAccessAllowed) + } + + @Test + fun `given user is not admin, when observing conversation, then apps access update is not allowed`() = runTest { + // Given + val conversationParticipantsData = ConversationParticipantsData(isSelfAnAdmin = false) + val details = testGroup + + val (_, viewModel) = UpdateAppsAccessViewModelArrangement() + .withConversationDetailUpdate(details) + .withConversationMembersUpdate(conversationParticipantsData) + .withAppsAllowedResult(true) + .arrange() + + advanceUntilIdle() + + // Then + assertEquals(false, viewModel.updateAppsAccessState.isUpdatingAppAccessAllowed) + } + + @Test + fun `given isAppsAllowed for team and conversation has SERVICE role, then state should reflect this to allow`() = runTest { + // Given + val conversationParticipantsData = ConversationParticipantsData(isSelfAnAdmin = true) + val conversation = TestConversation.GROUP().copy( + accessRole = listOf(Conversation.AccessRole.SERVICE) + ) + + val (_, viewModel) = UpdateAppsAccessViewModelArrangement() + .withAppsAllowedResult(true) + .withConversationDetailUpdate(TestConversationDetails.GROUP.copy(conversation = conversation)) + .withConversationMembersUpdate(conversationParticipantsData) + .arrange() + + advanceUntilIdle() + + // Then + assertEquals(true, viewModel.updateAppsAccessState.isAppAccessAllowed) + } + + @Test + fun `given isAppsAllowed for team but no SERVICE role for conversation, then state should reflect this to not allow`() = runTest { + // Given + val conversationParticipantsData = ConversationParticipantsData(isSelfAnAdmin = true) + val conversation = TestConversation.GROUP().copy( + accessRole = listOf(Conversation.AccessRole.GUEST) + ) + + val (_, viewModel) = UpdateAppsAccessViewModelArrangement() + .withAppsAllowedResult(true) + .withConversationDetailUpdate(TestConversationDetails.GROUP.copy(conversation = conversation)) + .withConversationMembersUpdate(conversationParticipantsData) + .arrange() + + advanceUntilIdle() + + // Then + assertEquals(false, viewModel.updateAppsAccessState.isAppAccessAllowed) + } + + @Test + fun `given team does not allow apps, when observing conversation, then apps access update is not allowed`() = runTest { + // Given + val conversationParticipantsData = ConversationParticipantsData(isSelfAnAdmin = true) + val details = testGroup + + val (_, viewModel) = UpdateAppsAccessViewModelArrangement() + .withConversationDetailUpdate(details) + .withConversationMembersUpdate(conversationParticipantsData) + .withAppsAllowedResult(false) + .arrange() + + advanceUntilIdle() + + // Then + assertEquals(false, viewModel.updateAppsAccessState.isUpdatingAppAccessAllowed) + assertEquals(false, viewModel.updateAppsAccessState.isAppAccessAllowed) + } + + @Test + fun `when onAppsDialogDismiss is called, then dialog is hidden and state is reverted`() = runTest { + // Given + val conversationParticipantsData = ConversationParticipantsData(isSelfAnAdmin = true) + val details = testGroup.copy( + conversation = testGroup.conversation.copy( + accessRole = listOf(Conversation.AccessRole.SERVICE, Conversation.AccessRole.TEAM_MEMBER) + ) + ) + + val (_, viewModel) = UpdateAppsAccessViewModelArrangement() + .withConversationDetailUpdate(details) + .withConversationMembersUpdate(conversationParticipantsData) + .withAppsAllowedResult(true) + .arrange() + + advanceUntilIdle() + val initialServicesAllowed = viewModel.updateAppsAccessState.isAppAccessAllowed + + viewModel.onAppsAccessUpdate(false) // Trigger disable, which shows dialog + + // When + viewModel.onAppsDialogDismiss() + + // Then + assertEquals(false, viewModel.updateAppsAccessState.shouldShowDisableAppsConfirmationDialog) + assertEquals(initialServicesAllowed, viewModel.updateAppsAccessState.isAppAccessAllowed) + assertEquals(false, viewModel.updateAppsAccessState.isLoadingAppsOption) + } + + companion object { + val conversationId = ConversationId("some-dummy-value", "dummyDomain") + val testGroup = ConversationDetails.Group.Regular( + Conversation( + id = conversationId, + name = "Conv Name", + type = Conversation.Type.OneOnOne, + teamId = TeamId("team_id"), + protocol = Conversation.ProtocolInfo.Proteus, + mutedStatus = MutedConversationStatus.AllAllowed, + removedBy = null, + lastNotificationDate = null, + lastModifiedDate = null, + access = listOf(Conversation.Access.CODE, Conversation.Access.INVITE), + accessRole = Conversation.defaultGroupAccessRoles.toMutableList().apply { + add(Conversation.AccessRole.GUEST) + add(Conversation.AccessRole.SERVICE) + }, + lastReadDate = Instant.parse("2022-04-04T16:11:28.388Z"), + creatorId = null, + receiptMode = Conversation.ReceiptMode.ENABLED, + messageTimer = null, + userMessageTimer = null, + archived = false, + archivedDateTime = null, + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + legalHoldStatus = Conversation.LegalHoldStatus.ENABLED + ), + hasOngoingCall = false, + isSelfUserMember = true, + selfRole = Conversation.Member.Role.Member, + wireCell = null, + ) + } +} + +internal class UpdateAppsAccessViewModelArrangement { + + @MockK + private lateinit var savedStateHandle: SavedStateHandle + + @MockK + lateinit var observeConversationDetails: ObserveConversationDetailsUseCase + + @MockK + lateinit var observeParticipantsForConversationUseCase: ObserveParticipantsForConversationUseCase + + @MockK + lateinit var changeAccessForAppsInConversationUseCase: ChangeAccessForAppsInConversationUseCase + + @MockK + lateinit var observeIsAppsAllowedForUsage: ObserveIsAppsAllowedForUsageUseCase + + @MockK + lateinit var observeSelfUser: ObserveSelfUserUseCase + + private val conversationDetailsFlow = MutableSharedFlow(replay = Int.MAX_VALUE) + + private val observeParticipantsForConversationFlow = + MutableSharedFlow(replay = Int.MAX_VALUE) + + private val viewModel by lazy { + UpdateAppsAccessViewModel( + dispatcher = TestDispatcherProvider(), + observeConversationDetails = observeConversationDetails, + observeConversationMembers = observeParticipantsForConversationUseCase, + changeAccessForAppsInConversation = changeAccessForAppsInConversationUseCase, + observeIsAppsAllowedForUsage = observeIsAppsAllowedForUsage, + selfUser = observeSelfUser, + savedStateHandle = savedStateHandle + ) + } + + val conversationId = ConversationId("some-dummy-value", "dummyDomain") + + init { + // Tests setup + MockKAnnotations.init(this, relaxUnitFun = true) + + every { savedStateHandle.navArgs() } returns UpdateAppsAccessNavArgs( + conversationId = conversationId, + updateAppsAccessParams = UpdateAppsAccessParams( + isGuestAllowed = true, + isAppsAllowed = true + ) + ) + + // Default empty values + coEvery { observeConversationDetails(any()) } returns flowOf() + coEvery { observeParticipantsForConversationUseCase(any()) } returns flowOf() + coEvery { observeSelfUser() } returns flowOf(TestUser.SELF_USER) + withAppsAllowedResult(false) + } + + fun withGuestDisabledNavArgs() = apply { + every { savedStateHandle.navArgs() } returns UpdateAppsAccessNavArgs( + conversationId = conversationId, + updateAppsAccessParams = UpdateAppsAccessParams( + isGuestAllowed = false, + isAppsAllowed = true + ) + ) + } + + fun withAppsAllowedResult(result: Boolean) = apply { + coEvery { observeIsAppsAllowedForUsage() } returns flowOf(result) + } + + suspend fun withConversationDetailUpdate(conversationDetails: ConversationDetails) = apply { + coEvery { observeConversationDetails(any()) } returns conversationDetailsFlow + .map { ObserveConversationDetailsUseCase.Result.Success(it) } + conversationDetailsFlow.emit(conversationDetails) + } + + suspend fun withConversationMembersUpdate(conversationParticipantsData: ConversationParticipantsData) = apply { + coEvery { observeParticipantsForConversationUseCase(any()) } returns observeParticipantsForConversationFlow + observeParticipantsForConversationFlow.emit(conversationParticipantsData) + } + + suspend fun withUpdateConversationAccessUseCaseReturns(result: UpdateConversationAccessRoleUseCase.Result) = apply { + coEvery { changeAccessForAppsInConversationUseCase(any(), any(), any()) } returns result + } + + fun arrange() = this to viewModel +} 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 9d28770d004..c9ef30c60f7 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 @@ -164,7 +164,7 @@ class ConversationMessagesViewModelArrangement { coEvery { observeAssetStatuses(any()) } returns flowOf(mapOf()) coEvery { conversationAudioMessagePlayer.audioSpeed } returns flowOf(AudioSpeed.NORMAL) - coEvery { conversationAudioMessagePlayer.fetchWavesMask(any(), any()) } returns Unit + coEvery { conversationAudioMessagePlayer.getOrBuildWavesMask(any(), any()) } returns Unit coEvery { conversationAudioMessagePlayer.playingAudioMessageFlow } returns flowOf(PlayingAudioMessage.None) coEvery { isWireCellFeatureEnabled() } returns false } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/SearchUserViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/SearchUserViewModelTest.kt index 442fb6efe69..5a7e48396fc 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/SearchUserViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/SearchUserViewModelTest.kt @@ -102,7 +102,7 @@ class SearchUserViewModelTest { val conversationId = ConversationId("id", "domain") val (arrangement, viewModel) = Arrangement() - .withAddMembersSearchNavArgs(AddMembersSearchNavArgs(conversationId, true)) + .withAddMembersSearchNavArgs(AddMembersSearchNavArgs(conversationId, true, true)) .withSearchResult( SearchUserResult( connected = listOf(), @@ -138,7 +138,7 @@ class SearchUserViewModelTest { runTest { val conversationId = ConversationId("id", "domain") val (_, viewModel) = Arrangement() - .withAddMembersSearchNavArgs(AddMembersSearchNavArgs(conversationId, true)) + .withAddMembersSearchNavArgs(AddMembersSearchNavArgs(conversationId, true, true)) .withIsFederationSearchAllowedResult(false) .withIsValidHandleResult(ValidateUserHandleResult.Valid("")) .withFederatedSearchParserResult( @@ -163,7 +163,7 @@ class SearchUserViewModelTest { runTest { val conversationId = ConversationId("id", "domain") val (_, viewModel) = Arrangement() - .withAddMembersSearchNavArgs(AddMembersSearchNavArgs(conversationId, true)) + .withAddMembersSearchNavArgs(AddMembersSearchNavArgs(conversationId, true, true)) .withIsFederationSearchAllowedResult(false) .withIsValidHandleResult(ValidateUserHandleResult.Valid("")) .withFederatedSearchParserResult( @@ -188,7 +188,7 @@ class SearchUserViewModelTest { runTest { val conversationId = ConversationId("id", "domain") val (_, viewModel) = Arrangement() - .withAddMembersSearchNavArgs(AddMembersSearchNavArgs(conversationId, true)) + .withAddMembersSearchNavArgs(AddMembersSearchNavArgs(conversationId, true, true)) .withIsFederationSearchAllowedResult(true) .withIsValidHandleResult(ValidateUserHandleResult.Valid("")) .withFederatedSearchParserResult( diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModelTest.kt index fd86b518dd3..9f9fdb03cf0 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModelTest.kt @@ -53,7 +53,8 @@ class AddMembersToConversationViewModelTest { withAddMembersSearchNavArgs( AddMembersSearchNavArgs( conversationId = ConversationId("conversationId", "domain"), - isAppsUsageAllowed = false + isConversationAppsEnabled = false, + isSelfPartOfATeam = true ) ) } @@ -81,7 +82,8 @@ class AddMembersToConversationViewModelTest { withAddMembersSearchNavArgs( AddMembersSearchNavArgs( conversationId = ConversationId("conversationId", "domain"), - isAppsUsageAllowed = false + isConversationAppsEnabled = false, + isSelfPartOfATeam = true ) ) } @@ -111,7 +113,8 @@ class AddMembersToConversationViewModelTest { withAddMembersSearchNavArgs( AddMembersSearchNavArgs( conversationId = ConversationId("conversationId", "domain"), - isAppsUsageAllowed = false + isConversationAppsEnabled = false, + isSelfPartOfATeam = true ) ) withAddMemberToConversationUseCase(AddMemberToConversationUseCase.Result.Success) diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt index cb10d8cbeb8..30819d767a7 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt @@ -247,7 +247,7 @@ internal class SendMessageViewModelArrangement { } fun withHandleUriAsset(result: HandleUriAssetUseCase.Result) = apply { - coEvery { handleUriAssetUseCase.invoke(any(), any(), any()) } returns result + coEvery { handleUriAssetUseCase.invoke(any(), any(), any(), any()) } returns result } fun withInformAboutVerificationBeforeMessagingFlag(flag: Boolean) = apply { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt index 54e9b0420db..c6919e5ec13 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt @@ -770,6 +770,7 @@ class SendMessageViewModelTest { assetWidth = null, assetHeight = null, audioLengthInMs = 0L, + audioNormalizedLoudness = null ) ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCaseTest.kt index ca244f66aec..939ece4878e 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCaseTest.kt @@ -180,7 +180,7 @@ class HandleUriAssetUseCaseTest { } fun withGetAssetBundleFromUri(assetBundle: AssetBundle?) = apply { - coEvery { fileManager.getAssetBundleFromUri(any(), any(), any(), any()) } returns assetBundle + coEvery { fileManager.getAssetBundleFromUri(any(), any(), any(), any(), any()) } returns assetBundle } fun withGetAssetSizeLimitUseCase(isImage: Boolean, assetSizeLimit: Long) = apply { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt index 1419fe7e4a3..dfab2bc3b09 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt @@ -29,13 +29,18 @@ import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogType import com.wire.android.ui.navArgs import com.wire.android.util.FileManager +import com.wire.kalium.cells.domain.usecase.GetCellFileUseCase +import com.wire.kalium.cells.domain.usecase.GetMessageAttachmentUseCase import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.right +import com.wire.kalium.logic.data.asset.AssetTransferStatus import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.conversation.ConversationDetails.OneOne import com.wire.kalium.logic.data.conversation.MutedConversationStatus.AllAllowed import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.message.CellAssetContent import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.OtherUser import com.wire.kalium.logic.data.user.SupportedProtocol @@ -61,6 +66,7 @@ import okio.Path.Companion.toPath import okio.buffer import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -103,7 +109,7 @@ class MediaGalleryViewModelTest { // Then coVerify(exactly = 1) { - arrangement.getImageData.invoke(mockedConversation.conversation.id, viewModel.imageAsset.messageId) + arrangement.getImageData.invoke(mockedConversation.conversation.id, dummyMessageId) arrangement.fileManager.saveToExternalStorage(any(), dummyDataPath, mockedImage.size.toLong(), any(), any()) } } @@ -140,7 +146,7 @@ class MediaGalleryViewModelTest { // When viewModel.deleteMessageDialogState.show( - DeleteMessageDialogState(true, viewModel.imageAsset.messageId, viewModel.imageAsset.conversationId) + DeleteMessageDialogState(true, dummyMessageId, dummyConversationId) ) // Then @@ -161,11 +167,12 @@ class MediaGalleryViewModelTest { .withSuccessfulImageData(imagePath, mockedImage.size.toLong()) .arrange() - // When - viewModel.deleteMessage("", true) + viewModel.actions.test { + // When + viewModel.deleteMessage("", true) - // Then - assertEquals(true, viewModel.mediaGalleryViewState.messageDeleted) + assertTrue(awaitItem() is MediaGalleryAction.Close) + } } @Test @@ -186,11 +193,191 @@ class MediaGalleryViewModelTest { viewModel.deleteMessage("", true) // Then - assertEquals(false, viewModel.mediaGalleryViewState.messageDeleted) assertEquals(MediaGallerySnackbarMessages.DeletingMessageError, awaitItem()) } } + @Test + fun givenCellAssetWithLocalPath_whenInitialisingViewModel_thenAssetWithLocalPathReturned() = runTest { + // Given + val (_, viewModel) = Arrangement() + .withNavArgs(cellAssetId = "cell-asset-id") + .withConversationDetails(mockedConversationDetails()) + .withAssetContent( + CellAssetContent( + id = "cell-asset-id", + versionId = "", + mimeType = "image/png", + localPath = "local/path", + assetPath = "asset/path", + assetSize = 1, + metadata = null, + transferStatus = AssetTransferStatus.SAVED_INTERNALLY + ) + ) + .arrange() + + // When + val state = viewModel.mediaGalleryViewState + + // Then + assertTrue(state.imageAsset is MediaGalleryImage.LocalAsset) + assertEquals("local/path", (state.imageAsset as MediaGalleryImage.LocalAsset).path) + } + + @Test + fun givenCellAssetWithUrl_whenInitialisingViewModel_thenAssetWithUrlReturned() = runTest { + // Given + val (_, viewModel) = Arrangement() + .withNavArgs(cellAssetId = "cell-asset-id") + .withConversationDetails(mockedConversationDetails()) + .withAssetContent( + CellAssetContent( + id = "cell-asset-id", + versionId = "", + mimeType = "image/png", + localPath = null, + assetPath = "asset/path", + contentUrl = "content/url", + previewUrl = "preview/url", + assetSize = 1, + metadata = null, + transferStatus = AssetTransferStatus.SAVED_INTERNALLY + ) + ) + .arrange() + + // When + val state = viewModel.mediaGalleryViewState + + // Then + assertTrue(state.imageAsset is MediaGalleryImage.UrlAsset) + assertEquals("content/url", (state.imageAsset as MediaGalleryImage.UrlAsset).url) + assertEquals("preview/url", state.imageAsset.placeholder) + } + + @Test + fun givenMessageMenuOptionsDisabled_whenShowingMenu_thenCorrectMenuItemsShown() = runTest { + val (_, viewModel) = Arrangement() + .withNavArgs(messageOptionsEnabled = false, isEphemeral = false) + .withConversationDetails(mockedConversationDetails()) + .arrange() + + viewModel.onOptionsClick() + + val state = viewModel.mediaGalleryViewState + + assertEquals( + listOf( + MediaGalleryMenuItem.DOWNLOAD, + MediaGalleryMenuItem.SHARE, + MediaGalleryMenuItem.DELETE, + ), + state.menuItems + ) + } + + @Test + fun givenMessageMenuOptionsDisabledAndEphemeral_whenShowingMenu_thenCorrectMenuItemsShown() = runTest { + val (_, viewModel) = Arrangement() + .withNavArgs(messageOptionsEnabled = false, isEphemeral = true) + .withConversationDetails(mockedConversationDetails()) + .arrange() + + viewModel.onOptionsClick() + + val state = viewModel.mediaGalleryViewState + + assertEquals( + listOf( + MediaGalleryMenuItem.DOWNLOAD, + MediaGalleryMenuItem.DELETE, + ), + state.menuItems + ) + } + + @Test + fun givenMessageMenuOptionsEnabledAndEphemeral_whenShowingMenu_thenCorrectMenuItemsShown() = runTest { + val (_, viewModel) = Arrangement() + .withNavArgs(messageOptionsEnabled = true, isEphemeral = true) + .withConversationDetails(mockedConversationDetails()) + .arrange() + + viewModel.onOptionsClick() + + val state = viewModel.mediaGalleryViewState + + assertEquals( + listOf( + MediaGalleryMenuItem.SHOW_DETAILS, + MediaGalleryMenuItem.DOWNLOAD, + MediaGalleryMenuItem.DELETE, + ), + state.menuItems + ) + } + + @Test + fun givenMessageMenuOptionsEnabledAndCellAsset_whenShowingMenu_thenCorrectMenuItemsShown() = runTest { + val (_, viewModel) = Arrangement() + .withNavArgs(messageOptionsEnabled = true, isEphemeral = false, cellAssetId = "cell-asset-id") + .withConversationDetails(mockedConversationDetails()) + .withAssetContent( + CellAssetContent( + id = "cell-asset-id", + versionId = "", + mimeType = "image/png", + localPath = null, + assetPath = "asset/path", + contentUrl = "content/url", + previewUrl = "preview/url", + assetSize = 1, + metadata = null, + transferStatus = AssetTransferStatus.SAVED_INTERNALLY + ) + ) + .arrange() + + viewModel.onOptionsClick() + + val state = viewModel.mediaGalleryViewState + + assertEquals( + listOf( + MediaGalleryMenuItem.REACT, + MediaGalleryMenuItem.SHOW_DETAILS, + MediaGalleryMenuItem.REPLY, + MediaGalleryMenuItem.SHARE_PUBLIC_LINK, + ), + state.menuItems + ) + } + + @Test + fun givenMessageMenuOptionsEnabled_whenShowingMenu_thenCorrectMenuItemsShown() = runTest { + val (_, viewModel) = Arrangement() + .withNavArgs(messageOptionsEnabled = true, isEphemeral = false, cellAssetId = null) + .withConversationDetails(mockedConversationDetails()) + .arrange() + + viewModel.onOptionsClick() + + val state = viewModel.mediaGalleryViewState + + assertEquals( + listOf( + MediaGalleryMenuItem.REACT, + MediaGalleryMenuItem.SHOW_DETAILS, + MediaGalleryMenuItem.REPLY, + MediaGalleryMenuItem.DOWNLOAD, + MediaGalleryMenuItem.SHARE, + MediaGalleryMenuItem.DELETE, + ), + state.menuItems + ) + } + private class Arrangement { @MockK private lateinit var savedStateHandle: SavedStateHandle @@ -207,21 +394,43 @@ class MediaGalleryViewModelTest { @MockK lateinit var deleteMessage: DeleteMessageUseCase + @MockK + lateinit var getAttachment: GetMessageAttachmentUseCase + + @MockK + lateinit var getCellFile: GetCellFileUseCase + init { // Tests setup - val dummyPrivateAsset = "some-conversationId:some-message-id:true:true" MockKAnnotations.init(this, relaxUnitFun = true) + every { savedStateHandle.navArgs() } returns MediaGalleryNavArgs( conversationId = dummyConversationId, - messageId = dummyPrivateAsset, + messageId = dummyMessageId, isSelfAsset = true, isEphemeral = false, - messageOptionsEnabled = true + messageOptionsEnabled = true, + cellAssetId = null, ) coEvery { deleteMessage(any(), any(), any()) } returns Either.Right(Unit) } + fun withNavArgs(messageOptionsEnabled: Boolean = true, isEphemeral: Boolean = false, cellAssetId: String? = null) = apply { + every { savedStateHandle.navArgs() } returns MediaGalleryNavArgs( + conversationId = dummyConversationId, + messageId = dummyMessageId, + isSelfAsset = true, + isEphemeral = isEphemeral, + messageOptionsEnabled = messageOptionsEnabled, + cellAssetId = cellAssetId, + ) + } + + fun withAssetContent(cellAssetContent: CellAssetContent) = apply { + coEvery { getAttachment(any()) } returns cellAssetContent.right() + } + fun withStoredData(assetData: ByteArray, assetPath: Path): Arrangement { fakeKaliumFileSystem.sink(assetPath).buffer().use { assetData @@ -256,7 +465,7 @@ class MediaGalleryViewModelTest { ) } returns CompletableDeferred( MessageAssetResult.Failure( - CoreFailure.Unknown(java.lang.RuntimeException()), + CoreFailure.Unknown(RuntimeException()), isRetryNeeded = true ) ) @@ -274,7 +483,9 @@ class MediaGalleryViewModelTest { TestDispatcherProvider(), getImageData, fileManager, - deleteMessage + deleteMessage, + getAttachment, + getCellFile, ) } @@ -324,5 +535,6 @@ class MediaGalleryViewModelTest { companion object { val fakeKaliumFileSystem = FakeKaliumFileSystem() val dummyConversationId = QualifiedID("a-value", "a-domain") + const val dummyMessageId = "some-conversationId:some-message-id:true: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 87d62c7f46d..2509ad255e6 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 @@ -114,7 +114,8 @@ class MessageComposerStateHolderTest { state.toEdit( messageId = "messageId", editMessageText = "edit_message_text", - mentions = listOf() + mentions = listOf(), + isMultipart = false, ) // then @@ -126,7 +127,8 @@ class MessageComposerStateHolderTest { state.toEdit( messageId = "messageId", editMessageText = "edit_message_text", - mentions = listOf() + mentions = listOf(), + isMultipart = false, ) assertInstanceOf(InputType.Editing::class.java, messageCompositionInputStateHolder.inputType).also { assertEquals(false, it.isEditButtonEnabled) @@ -138,7 +140,8 @@ class MessageComposerStateHolderTest { state.toEdit( messageId = "messageId", editMessageText = "edit_message_text", - mentions = listOf() + mentions = listOf(), + isMultipart = false, ) state.messageCompositionHolder.value.messageTextState.edit { append("some text") diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt index a9b814c63ee..703e833257e 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt @@ -25,7 +25,6 @@ import com.wire.android.datastore.GlobalDataStore import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.media.audiomessage.AudioFocusHelper import com.wire.android.media.audiomessage.AudioState -import com.wire.android.media.audiomessage.AudioWavesMaskHelper import com.wire.android.media.audiomessage.RecordAudioMessagePlayer import com.wire.android.ui.home.messagecomposer.recordaudio.RecordAudioViewModelTest.Arrangement.Companion.ASSET_SIZE_LIMIT import com.wire.android.util.CurrentScreen @@ -35,6 +34,7 @@ import com.wire.kalium.logic.data.call.CallStatus import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.asset.AudioNormalizedLoudnessBuilder import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCaseImpl import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase @@ -47,11 +47,9 @@ import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest -import okio.Path import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -import java.io.File @ExtendWith(CoroutineTestExtension::class) class RecordAudioViewModelTest { @@ -377,7 +375,7 @@ class RecordAudioViewModelTest { val context = mockk() val dispatchers = TestDispatcherProvider() val fakeKaliumFileSystem = FakeKaliumFileSystem() - val audioWavesMaskHelper = mockk() + val audioNormalizedLoudnessBuilder = mockk() val audioFocusHelper = mockk() val viewModel by lazy { @@ -391,7 +389,7 @@ class RecordAudioViewModelTest { generateAudioFileWithEffects = generateAudioFileWithEffects, globalDataStore = globalDataStore, dispatchers = dispatchers, - audioWavesMaskHelper = audioWavesMaskHelper, + audioNormalizedLoudnessBuilder = audioNormalizedLoudnessBuilder, kaliumFileSystem = fakeKaliumFileSystem, audioFocusHelper = audioFocusHelper ) @@ -427,8 +425,7 @@ class RecordAudioViewModelTest { coEvery { observeEstablishedCalls() } returns flowOf(listOf()) - every { audioWavesMaskHelper.getWaveMask(any()) } returns listOf() - every { audioWavesMaskHelper.getWaveMask(any()) } returns listOf() + coEvery { audioNormalizedLoudnessBuilder(any()) } returns byteArrayOf() every { audioFocusHelper.requestExclusive() } returns true every { audioFocusHelper.abandonExclusive() } returns Unit 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 a7070c61939..b48d29e2073 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 @@ -113,7 +113,22 @@ class MessageCompositionInputStateHolderTest { val initialText = "Hello" val newText = "Hello World" val (state, _) = Arrangement().withText(initialText).arrange() - state.toEdit(newText) + state.toEdit(newText, false) + + // When + val result = state.inputType as InputType.Editing + + // Then + result.isEditButtonEnabled shouldBeEqualTo true + } + + @Test + fun `given text has changed and is blank when transitioning to editing state then edit button is enabled for multipart`() = runTest { + // Given + val initialText = "Hello" + val newText = "" + val (state, _) = Arrangement().withText(initialText).arrange() + state.toEdit(newText, true) // When val result = state.inputType as InputType.Editing @@ -195,7 +210,7 @@ class MessageCompositionInputStateHolderTest { val (state, _) = Arrangement().withText(messageText).arrange() // When - state.toEdit(messageText) + state.toEdit(messageText, false) // Then state.inputType.shouldBeInstanceOf().isEditButtonEnabled shouldBeEqualTo false diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/ThemeExt.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/ThemeExt.kt index c133fb2f449..9e556f8f437 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/ThemeExt.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/ThemeExt.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.common import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable +import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.WireColorScheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDarkColorScheme @@ -27,6 +28,7 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireFixedColorScheme import com.wire.android.ui.theme.wireLightColorScheme import com.wire.android.ui.theme.wireTypography +import com.wire.android.ui.theme.withAccent import com.wire.kalium.logic.data.id.ConversationId import kotlin.math.absoluteValue @@ -34,7 +36,9 @@ import kotlin.math.absoluteValue fun dimensions() = MaterialTheme.wireDimensions @Composable -fun colorsScheme() = MaterialTheme.wireColorScheme +fun colorsScheme(accent: Accent? = null) = accent?.let { + MaterialTheme.wireColorScheme.withAccent(accent) +} ?: MaterialTheme.wireColorScheme @Composable fun darkColorsScheme() = MaterialTheme.wireDarkColorScheme diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/avatar/UserProfileAvatar.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/avatar/UserProfileAvatar.kt index 6cb9d3f6580..0d94fc248bf 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/avatar/UserProfileAvatar.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/avatar/UserProfileAvatar.kt @@ -68,7 +68,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.toSize -import com.google.common.primitives.Floats.min import com.wire.android.model.Clickable import com.wire.android.model.NameBasedAvatar import com.wire.android.model.UserAvatarData @@ -86,6 +85,7 @@ import com.wire.kalium.logic.data.user.UserAvailabilityStatus import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlin.math.absoluteValue +import kotlin.math.min import kotlin.math.sqrt import kotlin.time.Duration.Companion.hours diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/button/WireButtonDefaults.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/button/WireButtonDefaults.kt index d56e10259fe..ee89c5bab61 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/button/WireButtonDefaults.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/button/WireButtonDefaults.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.ui.graphics.Color +import com.wire.android.ui.theme.BubbleColors import com.wire.android.ui.theme.wireColorScheme @Composable @@ -104,6 +105,13 @@ fun wireTertiaryButtonColors() = wireButtonColors( positiveRipple = MaterialTheme.wireColorScheme.tertiaryButtonRipple, ) +@Composable +fun BubbleColors.secondaryButtonColors() = wireSecondaryButtonColors().copy( + enabled = secondary, + onEnabled = onSecondary, + enabledOutline = primary +) + @Suppress("ParameterListWrapping") @Composable private fun wireButtonColors( diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/card/WireOutlinedCard.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/card/WireOutlinedCard.kt new file mode 100644 index 00000000000..cf07ebe241e --- /dev/null +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/card/WireOutlinedCard.kt @@ -0,0 +1,115 @@ +/* + * Wire + * Copyright (C) 2025 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.card + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import com.wire.android.ui.common.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.PreviewMultipleThemes + +@Composable +fun WireOutlinedCard( + title: String, + textContent: String, + modifier: Modifier = Modifier, + mainActionButtonText: String? = null, + onMainActionClick: (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + OutlinedCard( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = colorsScheme().secondaryButtonSelected), + border = BorderStroke(dimensions().spacing1x, colorsScheme().secondaryButtonSelectedOutline), + ) { + Row( + modifier = Modifier.padding( + start = dimensions().spacing8x, + top = dimensions().spacing8x, + end = dimensions().spacing8x, + ) + ) { + trailingIcon?.invoke() + Text( + modifier = Modifier.padding(start = dimensions().spacing8x), + text = title, + style = MaterialTheme.wireTypography.body02, + color = colorsScheme().onBackground + ) + } + Text( + modifier = Modifier.padding( + start = dimensions().spacing8x, + top = dimensions().spacing4x, + end = dimensions().spacing8x + ), + text = textContent, + style = MaterialTheme.wireTypography.body01, + color = colorsScheme().onBackground + ) + + if (!mainActionButtonText.isNullOrEmpty()) { + onMainActionClick?.let { onClick -> + WireSecondaryButton( + modifier = Modifier + .padding(dimensions().spacing8x) + .height(dimensions().createTeamInfoCardButtonHeight), + text = mainActionButtonText, + onClick = onClick, + fillMaxWidth = false, + minSize = dimensions().buttonSmallMinSize, + minClickableSize = dimensions().buttonMinClickableSize, + ) + } + } + if (onMainActionClick == null) Spacer(modifier = Modifier.height(dimensions().spacing8x)) + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewWireOutlinedCard() = WireTheme { + WireOutlinedCard( + title = "Your team doesn't use apps yet.", + textContent = "To enable apps, please contact your team admin.", + mainActionButtonText = "Learn more", + trailingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_attention), + contentDescription = null, + tint = colorsScheme().onBackground + ) + } + ) +} diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/AccentSwatch.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/AccentSwatch.kt index 2e5a1653892..83820cc9b7f 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/AccentSwatch.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/AccentSwatch.kt @@ -261,9 +261,9 @@ private val DarkRoles = AccentRoleMap( bubbleOtherPrimaryOnSecondary = Tone.T500, ) -fun WireColorScheme.withAccent(accent: Accent, isDark: Boolean): WireColorScheme { - val swatch = accent.asSwatch().let { if (isDark) it.dark else it.light } - val roles = if (isDark) DarkRoles else LightRoles +fun WireColorScheme.withAccent(accent: Accent): WireColorScheme { + val swatch = accent.asSwatch().let { if (useDarkSystemBarIcons) it.light else it.dark } + val roles = if (useDarkSystemBarIcons) LightRoles else DarkRoles return copy( primary = swatch[roles.primary], diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/Theme.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/Theme.kt index d5b7581d1f6..7fdf206fbcf 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/Theme.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/Theme.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.theme -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState @@ -39,8 +38,7 @@ fun WireTheme( content: @Composable () -> Unit ) { val isPreview = LocalInspectionMode.current - val isDarkTheme = isSystemInDarkTheme() - val wireColorScheme = remember(baseColorScheme, accent) { baseColorScheme.withAccent(accent, isDarkTheme) } + val wireColorScheme = remember(baseColorScheme, accent) { baseColorScheme.withAccent(accent) } @Suppress("SpreadOperator") CompositionLocalProvider( LocalWireColors provides wireColorScheme, diff --git a/core/ui-common/src/main/res/values-de/strings.xml b/core/ui-common/src/main/res/values-de/strings.xml index b75aec6a62d..b6078a6b623 100644 --- a/core/ui-common/src/main/res/values-de/strings.xml +++ b/core/ui-common/src/main/res/values-de/strings.xml @@ -25,6 +25,7 @@ Schließen Hauptnavigation Ausstehende Genehmigung der Kontaktanfrage + Weitere Optionen anzeigen Gast Extern App @@ -42,4 +43,5 @@ Bernstein Petrol Lila + Keine Profilfarbe diff --git a/core/ui-common/src/main/res/values-ru/strings.xml b/core/ui-common/src/main/res/values-ru/strings.xml index 7225bc30e6d..71dc0365417 100644 --- a/core/ui-common/src/main/res/values-ru/strings.xml +++ b/core/ui-common/src/main/res/values-ru/strings.xml @@ -50,4 +50,5 @@ Янтарный Розовый Фиолетовый + Нет diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index 7d302a59e42..305143fef22 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -532,7 +532,7 @@ class CellViewModel @Inject constructor( } suspend fun loadTags() { - getAllTagsUseCase().onSuccess { updated -> _tags.update { updated } } + getAllTagsUseCase().onSuccess { updated -> _tags.update { updated.sorted().toSet() } } // apply delay to avoid too frequent requests delay(30.seconds) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/createfolder/CreateFolderScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/createfolder/CreateFolderScreen.kt index 64e4bc531bc..34bf53cbd09 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/createfolder/CreateFolderScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/createfolder/CreateFolderScreen.kt @@ -177,7 +177,7 @@ private fun computeNameErrorState( R.string.rename_long_folder_name_error DisplayNameState.NameError.TextFieldError.InvalidNameError -> - R.string.create_folder_invalid_name + R.string.rename_invalid_name } WireTextFieldState.Error(stringResource(id = messageRes)) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModel.kt index 12e3191fdb8..d1761df2c06 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModel.kt @@ -66,7 +66,7 @@ class AddRemoveTagsViewModel @Inject constructor( private val _suggestedTags = MutableStateFlow>(emptySet()) internal val suggestedTags = allTags.combine(addedTags) { all, added -> - all.filter { it !in added }.toSet() + all.filter { it !in added }.toSet().sorted() }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptySet()) init { diff --git a/features/cells/src/main/res/values-cv/strings.xml b/features/cells/src/main/res/values-cv/strings.xml deleted file mode 100644 index c601b94f780..00000000000 --- a/features/cells/src/main/res/values-cv/strings.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - Retry - There was an error loading the files list. Please try again - There are no files yet You\'ll find all files shared across your conversations here. - There are no files in this conversation. - No files found. - Save - Share - Share via Link - Delete - Delete permanently - Restore - Move - Download folder - Copy link - Share link - Link copied to clipboard - %1$s in %2$s - %1$s by %2$s - Your file will be shared via a public link. Only those with the link can view it—ensure you trust your recipients. - Your folder will be shared via a public link. Only those with the link can view it—ensure you trust your recipients. - Enable public link - Share file via link - Share folder via link - No application found to open the file. - Failed to load. Please try again later. - Action failed. - Cancel - Delete file - Please confirm deleting file from cell. - Reload - Failed to remove public link. - Failed to load public link. - Failed to create public link. - Download failed. - This file is not downloaded yet. Do you want to download it now? - Download - This file is being downloaded… - Failed to share link. - Files - Creating public link… - New - New - Create Folder - Folder Name - Loading files… - There was an error while creating a new folder. Please try again - Move to Folder - Move Here - There are no subfolders in this folder. - Item was moved to folder. - Unable to move the Item. - diff --git a/features/cells/src/main/res/values-de/strings.xml b/features/cells/src/main/res/values-de/strings.xml index dcf3fed4827..5e8f4f109b0 100644 --- a/features/cells/src/main/res/values-de/strings.xml +++ b/features/cells/src/main/res/values-de/strings.xml @@ -18,6 +18,10 @@ --> Wiederholen + Die Liste der Dateien konnte nicht geladen werden. Bitte versuchen Sie es erneut. + Beim Laden der Dateiliste ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut. + Dateien werden nicht geladen + Bei Unterhaltungen mit Dateiverwaltung finden Sie hier die geteilten Dateien. Hier finden Sie alle Dateien und Ordner, die in dieser Unterhaltung geteilt wurden. Versuchen Sie, Ihre Suche anzupassen Speichern @@ -27,15 +31,19 @@ Endgültig löschen Wiederherstellen Tags hinzufügen oder entfernen + In Ordner verschieben Herunterladen Link kopieren Link teilen Link in Zwischenablage kopiert %1$s in %2$s %1$s von %2$s + Laden Sie Ihre Datei hoch und teilen Sie sie über einen öffentlichen Link. Alle Empfänger können sie ansehen und herunterladen. + Laden Sie Ihren Ordner hoch und teilen Sie ihn über einen öffentlichen Link. Alle Empfänger können den Ordner und seine Dateien ansehen und herunterladen. Öffentlichen Link erstellen Datei über Link teilen Ordner per Link teilen + Laden nicht möglich. Bitte versuchen Sie es später erneut. Abbrechen Datei löschen Ordner löschen @@ -44,7 +52,13 @@ Dies wird die Datei %1$s für alle Teilnehmer endgültig löschen. Dies wird den Ordner %1$s für alle Teilnehmer endgültig löschen. Neu laden + Öffentlicher Link kann nicht entfernt werden. + Öffentlicher Link kann nicht geladen werden. + Öffentlicher Link kann nicht erstellt werden. + Diese Datei wurde noch nicht heruntergeladen. Jetzt herunterladen? Herunterladen + Datei wird heruntergeladen… + Link kann nicht geteilt werden. Dateien Öffentlicher Link wird erstellt… Neu @@ -52,14 +66,23 @@ Ordner erstellen Ordnername Dateien werden geladen… + Ordner kann nicht erstellt werden. Bitte versuchen Sie es erneut + In Ordner verschieben + Hierhin verschieben Es gibt keine Unterordner in diesem Ordner. Objekt wurde in Ordner verschoben. + Objekt kann nicht verschoben werden. + Papierkorb Papierkorb öffnen Hier finden Sie alle gelöschten Dateien und Ordner. + Datei wiederherstellen + Ordner wiederherstellen Diese Datei %1$s wird wiederhergestellt und ist für alle Teilnehmer dieser Unterhaltung wieder verfügbar. Wiederherstellen Ordner kann nicht wiederhergestellt werden Datei kann nicht wiederhergestellt werden + Ordner konnte nicht wiederhergestellt werden. + Datei konnte nicht wiederhergestellt werden. Übergeordneten Ordner wiederherstellen Sie können keine Ordner oder Dateien aus einem gelöschten Ordner wiederherstellen. Um das gewünschte Objekt wiederzuverwenden, müssen Sie den übergeordneten Ordner wiederherstellen.\n\nDer Ordner %1$s mit allen Dateien und Unterordnern wird wiederhergestellt und steht allen Teilnehmern dieser Unterhaltung zur Verfügung. Datei umbenennen @@ -73,19 +96,28 @@ Ordnernamen eingeben Datei wurde umbenannt Ordner wurde umbenannt + Umbenennen nicht möglich + Verwenden Sie einen Namen ohne \"/\" + Eine Datei mit diesem Namen ist bereits vorhanden Filter Tags Tags auswählen Alle löschen Anwenden + Tags hinzufügen oder entfernen + Alle Mitglieder Ihres Teams können diese Tags sehen und verwenden. Hinzugefügte Tags Tag hinzufügen + Tag-Name eingeben + Verwenden Sie ein Tag ohne Sonderzeichen , ; \/ \\ " ' < > Noch keine Tags erstellt. Tags wurden bearbeitet - Verwenden Sie einen Namen ohne \"/\" oder \".\" + Tags konnten nicht hinzugefügt oder entfernt werden Es gibt noch keine Dateien oder Ordner Keine Ergebnisse gefunden Versuchen Sie, Ihre Filter anzupassen. + Datei wurde gelöscht Ordner wurde gelöscht + Datei wurde dauerhaft gelöscht Ordner wurde dauerhaft gelöscht diff --git a/features/cells/src/main/res/values-ru/strings.xml b/features/cells/src/main/res/values-ru/strings.xml index 814bf1cc570..e7587cf343f 100644 --- a/features/cells/src/main/res/values-ru/strings.xml +++ b/features/cells/src/main/res/values-ru/strings.xml @@ -19,7 +19,8 @@ Повторить Ошибка - Произошла ошибка при загрузке списка файлов. Пожалуйста, попробуйте еще раз + Не удалось загрузить список файлов. Пожалуйста, повторите попытку. + Что-то пошло не так при загрузке списка файлов. Пожалуйста, попробуйте снова. Невозможно загрузить файлы Не удалось загрузить список ваших файлов. Пожалуйста, проверьте подключение к интернету и повторите попытку. Для бесед, в которых используется совместная работа с файлами, вы найдете общие файлы здесь. @@ -39,13 +40,13 @@ Ссылка скопирована в буфер обмена %1$s в %2$s %1$s от %2$s - Загрузите свой файл и поделитесь им с помощью общедоступной ссылки. Все получатели смогут просмотреть и скачать папку и ее содержимое. - Загрузите папку и поделитесь ею с помощью общедоступной ссылки. Все получатели смогут просмотреть и скачать ее. + Загрузите файл и поделитесь им с помощью общедоступной ссылки. Все получатели смогут просмотреть и скачать его. + Загрузите папку и поделитесь ею с помощью общедоступной ссылки. Все получатели смогут просматривать и загружать папку и ее файлы. Создать публичную ссылку Поделиться файлом по ссылке Поделиться папкой по ссылке Не найдено приложение для открытия файла. - Не удалось загрузить. Пожалуйста, повторите попытку позже. + Не удается загрузить. Пожалуйста, повторите попытку позже. Действие не удалось. Отмена Удалить файл @@ -55,14 +56,14 @@ Это навсегда удалит файл %1$s для всех участников. Это навсегда удалит папку %1$s для всех участников. Перезагрузить - Не удалось удалить публичную ссылку. - Не удалось загрузить публичную ссылку. - Не удалось создать публичную ссылку. + Не удается удалить общедоступную ссылку. + Не удается загрузить общедоступную ссылку. + Не удается создать общедоступную ссылку. Загрузка не удалась. Этот файл еще не скачан. Скачать сейчас? Скачать - Этот файл скачивается… - Не удалось поделиться ссылкой. + Загрузка файла… + Не удается поделиться ссылкой. Файлы Создание публичной ссылки… Новая @@ -70,12 +71,12 @@ Создать папку Название папки Загрузка файлов… - При создании новой папки произошла ошибка. Пожалуйста, попробуйте еще раз + Не удается создать папку. Пожалуйста, попробуйте снова Переместить в папку Переместить сюда В этой папке нет подпапок. Элемент был перемещен в папку. - Не удалось переместить элемент. + Не удается переместить элемент. Открыть параметры файлов Корзина Открыть корзину @@ -87,8 +88,8 @@ Восстановить Невозможно восстановить папку Невозможно восстановить файл - Из-за ошибки не удалось восстановить папку. - Из-за ошибки не удалось восстановить файл. + Не удается восстановить вашу папку. + Не удается восстановить ваш файл. OK Восстановить родительскую папку Вы не можете восстановить папки или файлы из удаленной папки. Чтобы повторно использовать нужный элемент, вы должны восстановить его родительскую папку.\n\n Папка %1$s со всеми файлами и подпапками будет восстановлена и доступна всем участникам этой беседы. @@ -103,7 +104,7 @@ Введите название папки Файл переименован Папка переименована - Не удалось переименовать + Не удается переименовать Используйте название без \"/\" Файл с таким названием уже существует Фильтр @@ -112,15 +113,14 @@ Очистить все Применить Добавить или удалить теги - Все члены вашей команды могут видеть и использовать теги, которые вы создаете. + Все члены вашей команды могут видеть и использовать эти теги. Теги добавлены Добавить тег Введите название тега Используйте тег без специальных символов , ; \/ \\ " ' < > Теги еще не добавлены. Теги были изменены - Не удалось изменить теги - Используйте название без \"/\" или \".\" + Не удается добавить или удалить теги Файлов и папок пока нет Ничего не найдено Попробуйте изменить фильтры. diff --git a/features/cells/src/main/res/values-si/strings.xml b/features/cells/src/main/res/values-si/strings.xml index 5cd178b68d4..44de513375d 100644 --- a/features/cells/src/main/res/values-si/strings.xml +++ b/features/cells/src/main/res/values-si/strings.xml @@ -18,23 +18,17 @@ --> නැවත - ගොනු ලැයිස්තුව පූරණය කිරීමේදී දෝෂයක් ඇති විය. කරුණාකර නැවත උත්සාහ කරන්න. සබැඳිය පසුරු පුවරුවට පිටපත් කරන ලදී %2$sකින් %1$s %1$s %2$sවිසින් සබැඳිය හරහා ගොනුව බෙදා ගන්න ගොනුව විවෘත කිරීමට යෙදුමක් හමු නොවීය. - පූරණය කිරීමට අසමත් විය. කරුණාකර පසුව නැවත උත්සාහ කරන්න. ක්‍රියාව අසාර්ථක විය. අවලංගු කරන්න නැවත පූරණය කරන්න - පොදු සබැඳිය ඉවත් කිරීමට අසමත් විය. - පොදු සබැඳිය පූරණය කිරීමට අසමත් විය. - පොදු සබැඳිය නිර්මාණය කිරීමට අසමත් විය. බාගැනීම අසාර්ථක විය. මෙම ගොනුව තවම බාගත කර නැත. ඔබට එය දැන් බාගත කිරීමට අවශ්‍යද? බාගත කරන්න - සබැඳිය බෙදා ගැනීමට අසමත් විය. ගොනු පොදු සබැඳියක් නිර්මාණය කිරීම… අලුත් diff --git a/features/cells/src/main/res/values-tr/strings.xml b/features/cells/src/main/res/values-tr/strings.xml index 476f8678c1c..e28cbdf6d1d 100644 --- a/features/cells/src/main/res/values-tr/strings.xml +++ b/features/cells/src/main/res/values-tr/strings.xml @@ -18,7 +18,6 @@ --> Yeniden dene - Dosya listesi yüklenirken bir hata oluştu. Lütfen tekrar deneyin. Kaydet Paylaş Sil @@ -30,12 +29,10 @@ Bağlantı panoya kopyalandı %2$s içinden %1$s %2$s tarafından %1$s - Klasörünüzü genel bir bağlantı aracılığıyla yükleyin ve paylaşın. Tüm alıcılar görüntüleyebilir ve indirebilir. Genel bağlantı oluştur Dosyayı bağlantı yoluyla paylaş Klasörü bağlantı yoluyla paylaş Dosyayı açacak bir uygulama bulunamadı. - Yüklenemedi. Lütfen daha sonra tekrar deneyin. İşlem başarısız oldu. İptal Dosyayı sil @@ -44,14 +41,9 @@ Bu, tüm katılımcılar için %1$s dosyasını kalıcı olarak silecektir. Bu, tüm katılımcılar için %1$s klasörünü kalıcı olarak silecektir. Tekrar yükle - Genel bağlantı kaldırılamadı. - Genel bağlantı yüklenemedi. - Genel bağlantı oluşturulamadı. İndirme başarısız oldu. Bu dosya henüz indirilmedi. Şimdi indirmek ister misiniz? İndir - Bu dosya indiriliyor… - Bağlantı paylaşımı başarısız oldu. Dosyalar Genel bağlantı oluşturuluyor… Yeni @@ -59,12 +51,9 @@ Klasör oluştur Klasör ismi Dosyalar yükleniyor… - Yeni bir klasör oluşturulurken bir hata oluştu. Lütfen tekrar deneyin. - Klasöre taşı Buraya taşı Bu klasörde alt klasör bulunmamaktadır. Öğe klasöre taşındı. - Öğe taşınamadı. Dosya seçeneklerini aç Geri dönüşüm kutusu Geri Dönüşüm Kutusunu Aç @@ -80,16 +69,13 @@ Bir klasör adı girin Dosya yeniden adlandırıldı Klasör yeniden adlandırıldı - Yeniden adlandırılamadı Filtre Etiketler etiketleri seç Tümünü temizle Uygula - Oluşturduğunuz etiketleri ekibinizdeki herkes görebilir ve kullanabilir. Eklenen etiketler Etiket ekle Etiket adı girin Etiketler düzenlendi - Etiketler düzenlenemedi diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index 243df84d79e..60ea83f8725 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -20,7 +20,7 @@ Error The list of files couldn\'t be loaded. Please try again. Something went wrong loading the list of files. Please try again. - Can’t Load Files + Can’t load files Your list of files could not be loaded. Please check your Internet connection and try again. For conversations that use file collaboration, you\'ll find shared files here. You\'ll find all files and folders shared in this conversation here. @@ -32,7 +32,7 @@ Delete permanently Restore Add or Remove Tags - Move to Folder + Move to folder Download Copy Link Share Link @@ -61,7 +61,7 @@ Download failed. This file is not downloaded yet. Do you want to download it now? Download - This file is being downloaded… + Downloading file… Unable to share link. Files Creating public link… @@ -70,12 +70,12 @@ Create Folder Folder Name Loading files… - There was an error while creating a new folder. Please try again - Move to Folder + Unable to create folder. Please try again + Move to folder Move Here There are no subfolders in this folder. Item was moved to folder. - Unable to move the Item. + Unable to move the item. Open files options Recycle Bin Open Recycle Bin @@ -111,7 +111,7 @@ select tags Clear All Apply - Add or Remove Tags + Add or remove tags All members of your team can see and use those tags. Added tags Add tag @@ -120,7 +120,7 @@ No tags were added yet. Tags were edited Unable to add or remove tags - Use a name without "/" or "." + Use a name without "/" There are no files or folders yet No results found Try adjusting your filters. diff --git a/kalium b/kalium index 034a556eb8b..482d56255a0 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 034a556eb8bb7e537b8b3e38346b9a2af47f3125 +Subproject commit 482d56255a0947efd95060a6688e035f77103d3e