From d1418534e957d812966b9d5c6710f2737c060e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BBerko?= Date: Thu, 28 Dec 2023 11:09:13 +0100 Subject: [PATCH] feat: media files tab epic [WPB-4914] (#2556) Co-authored-by: Alexandre Ferris --- .../android/di/accountScoped/MessageModule.kt | 24 +- .../com/wire/android/ui/common/Extensions.kt | 34 +++ .../home/conversations/ConversationScreen.kt | 2 +- .../ConversationAssetMessagesViewModel.kt | 100 ++------ .../ConversationAssetMessagesViewState.kt | 11 +- .../media/ConversationMediaScreen.kt | 135 +++++++++-- .../media/EmptyMediaContentScreen.kt | 80 +++++++ .../conversations/media/FileAssetsContent.kt | 145 ++++++++++++ .../conversations/media/ImageAssetsContent.kt | 223 +++++++++++------- .../messagetypes/image/ImageMessageTypes.kt | 14 +- ...GetAssetMessagesFromConversationUseCase.kt | 115 +++++++++ ...ageAssetMessagesFromConversationUseCase.kt | 103 ++++++++ .../android/util/time/TimeZoneProvider.kt | 25 ++ app/src/main/res/values/strings.xml | 3 +- ...ssetMessagesFromConversationUseCaseTest.kt | 120 ++++++++++ kalium | 2 +- 16 files changed, 935 insertions(+), 201 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/media/EmptyMediaContentScreen.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetAssetMessagesFromConversationUseCase.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCase.kt create mode 100644 app/src/main/kotlin/com/wire/android/util/time/TimeZoneProvider.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCaseTest.kt diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt index f60d1179c0..239c4ca920 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 @@ -21,8 +21,10 @@ import com.wire.android.di.CurrentAccount import com.wire.android.di.KaliumCoreLogic import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.user.UserId -import com.wire.kalium.logic.feature.asset.GetAssetMessagesForConversationUseCase +import com.wire.kalium.logic.feature.asset.GetImageAssetMessagesForConversationUseCase import com.wire.kalium.logic.feature.asset.GetMessageAssetUseCase +import com.wire.kalium.logic.feature.asset.ObservePaginatedAssetImageMessages +import com.wire.kalium.logic.feature.asset.GetPaginatedFlowOfAssetMessageByConversationIdUseCase import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageUseCase import com.wire.kalium.logic.feature.asset.UpdateAssetMessageDownloadStatusUseCase import com.wire.kalium.logic.feature.message.DeleteMessageUseCase @@ -42,6 +44,8 @@ import com.wire.kalium.logic.feature.message.SendTextMessageUseCase import com.wire.kalium.logic.feature.message.ToggleReactionUseCase import com.wire.kalium.logic.feature.message.composite.SendButtonActionMessageUseCase import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase +import com.wire.kalium.logic.feature.message.getPaginatedFlowOfAssetMessageByConversationId +import com.wire.kalium.logic.feature.message.observePaginatedImageAssetMessageByConversationId import com.wire.kalium.logic.feature.message.getPaginatedFlowOfMessagesByConversation import com.wire.kalium.logic.feature.message.getPaginatedFlowOfMessagesBySearchQueryAndConversation import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase @@ -154,8 +158,22 @@ class MessageModule { @ViewModelScoped @Provides - fun provideGetAssetMessagesUseCase(messageScope: MessageScope): GetAssetMessagesForConversationUseCase = - messageScope.getAssetMessagesByConversation + fun provideGetImageAssetMessagesByConversationUseCase(messageScope: MessageScope): GetImageAssetMessagesForConversationUseCase = + messageScope.getImageAssetMessagesByConversation + + @ViewModelScoped + @Provides + fun provideGetPaginatedFlowOfAssetMessageByConversationId( + messageScope: MessageScope + ): GetPaginatedFlowOfAssetMessageByConversationIdUseCase = + messageScope.getPaginatedFlowOfAssetMessageByConversationId + + @ViewModelScoped + @Provides + fun provideGetPaginatedFlowOfImageAssetMessageByConversationId( + messageScope: MessageScope + ): ObservePaginatedAssetImageMessages = + messageScope.observePaginatedImageAssetMessageByConversationId @ViewModelScoped @Provides diff --git a/app/src/main/kotlin/com/wire/android/ui/common/Extensions.kt b/app/src/main/kotlin/com/wire/android/ui/common/Extensions.kt index d4df9a98b3..b4cbc61010 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/Extensions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/Extensions.kt @@ -50,11 +50,20 @@ import com.google.accompanist.placeholder.shimmer import com.wire.android.R import com.wire.android.model.ClickBlockParams import com.wire.android.model.Clickable +import com.wire.android.ui.home.conversations.model.messagetypes.asset.UIAssetMessage import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.util.LocalSyncStateObserver +import com.wire.kalium.logic.data.message.Message import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import java.time.format.TextStyle +import java.util.Locale import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -145,3 +154,28 @@ fun Flow.collectAsStateLifecycleAware( fun StateFlow.collectAsStateLifecycleAware( context: CoroutineContext = EmptyCoroutineContext ): State = collectAsStateLifecycleAware(value, context) + +fun monthYearHeader(month: Int, year: Int): String { + val currentYear = Instant.fromEpochMilliseconds(System.currentTimeMillis()).toLocalDateTime( + TimeZone.currentSystemDefault()).year + val monthYearInstant = LocalDateTime(year = year, monthNumber = month, 1, 0, 0, 0) + + val monthName = monthYearInstant.month.getDisplayName(TextStyle.FULL_STANDALONE, Locale.getDefault()) + return if (year == currentYear) { + // If it's the current year, display only the month name + monthName + } else { + // If it's not the current year, display both the month name and the year + "$monthName $year" + } +} + +fun List.toImageAssetGroupedByMonthAndYear(timeZone: TimeZone) = this.groupBy { asset -> + val localDateTime = asset.time.toLocalDateTime(timeZone) + monthYearHeader(year = localDateTime.year, month = localDateTime.monthNumber) +} + +fun List.toGenericAssetGroupedByMonthAndYear(timeZone: TimeZone) = this.groupBy { message -> + val localDateTime = message.date.toInstant().toLocalDateTime(timeZone) + monthYearHeader(year = localDateTime.year, month = localDateTime.monthNumber) +} 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 0c85782c51..79bb39a20a 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 @@ -784,7 +784,7 @@ private fun ConversationScreenContent( } @Composable -private fun SnackBarMessage( +fun SnackBarMessage( composerMessages: SharedFlow, conversationMessages: SharedFlow ) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationAssetMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationAssetMessagesViewModel.kt index a74f09767e..8b4a25d97d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationAssetMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationAssetMessagesViewModel.kt @@ -23,30 +23,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.wire.android.mapper.UIAssetMapper import com.wire.android.navigation.SavedStateViewModel import com.wire.android.ui.home.conversations.ConversationNavArgs +import com.wire.android.ui.home.conversations.usecase.GetAssetMessagesFromConversationUseCase +import com.wire.android.ui.home.conversations.usecase.ObserveImageAssetMessagesFromConversationUseCase import com.wire.android.ui.navArgs -import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.id.QualifiedID -import com.wire.kalium.logic.data.message.Message -import com.wire.kalium.logic.feature.asset.GetAssetMessagesForConversationUseCase -import com.wire.kalium.logic.feature.asset.GetMessageAssetUseCase -import com.wire.kalium.logic.feature.asset.MessageAssetResult import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel @Suppress("LongParameterList", "TooManyFunctions") class ConversationAssetMessagesViewModel @Inject constructor( override val savedStateHandle: SavedStateHandle, - private val dispatchers: DispatcherProvider, - private val getAssets: GetAssetMessagesForConversationUseCase, - private val getPrivateAsset: GetMessageAssetUseCase, - private val assetMapper: UIAssetMapper, + private val getImageMessages: ObserveImageAssetMessagesFromConversationUseCase, + private val getAssetMessages: GetAssetMessagesFromConversationUseCase, ) : SavedStateViewModel(savedStateHandle) { private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() @@ -55,80 +47,30 @@ class ConversationAssetMessagesViewModel @Inject constructor( var viewState by mutableStateOf(ConversationAssetMessagesViewState()) private set - private var continueLoading = true - private var isLoading = false - private var currentOffset: Int = 0 - init { + loadImages() loadAssets() } - fun continueLoading(shouldContinue: Boolean) { - if (shouldContinue) { - if (!continueLoading) { - continueLoading = true - loadAssets() - } - } else { - continueLoading = false - } - } - private fun loadAssets() = viewModelScope.launch { - if (isLoading) { - return@launch - } - isLoading = true - try { - while (continueLoading) { - val uiAssetList = withContext(dispatchers.io()) { - getAssets.invoke( - conversationId = conversationId, - limit = BATCH_SIZE, - offset = currentOffset - ).map(assetMapper::toUIAsset) - } + val assetsResult = getAssetMessages.invoke( + conversationId = conversationId, + initialOffset = 0 + ) - // imitate loading new asset batch - viewState = viewState.copy(messages = viewState.messages.plus(uiAssetList.map { - it.copy( - downloadStatus = if (it.assetPath == null && it.downloadStatus != Message.DownloadStatus.FAILED_DOWNLOAD) { - Message.DownloadStatus.DOWNLOAD_IN_PROGRESS - } else { - it.downloadStatus - } - ) - }).toImmutableList()) - - if (uiAssetList.size >= BATCH_SIZE) { - val uiMessages = uiAssetList.map { uiAsset -> - if (uiAsset.assetPath == null) { - val assetPath = withContext(dispatchers.io()) { - when (val asset = getPrivateAsset.invoke(uiAsset.conversationId, uiAsset.messageId).await()) { - is MessageAssetResult.Failure -> null - is MessageAssetResult.Success -> asset.decodedAssetPath - } - } - uiAsset.copy(assetPath = assetPath) - } else { - uiAsset - } - } - currentOffset += BATCH_SIZE - - viewState = viewState.copy( - messages = viewState.messages.dropLast(uiMessages.size).plus(uiMessages).toImmutableList(), - ) - } else { - continueLoading = false - } - } - } finally { - isLoading = false - } + viewState = viewState.copy( + assetMessages = assetsResult + ) } - companion object { - const val BATCH_SIZE = 5 + private fun loadImages() = viewModelScope.launch { + val imageAssetsResult = getImageMessages.invoke( + conversationId = conversationId, + initialOffset = 0 + ) + + viewState = viewState.copy( + imageMessages = imageAssetsResult + ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationAssetMessagesViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationAssetMessagesViewState.kt index 6dc6832f9c..69dc9e214b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationAssetMessagesViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationAssetMessagesViewState.kt @@ -21,11 +21,14 @@ package com.wire.android.ui.home.conversations.media import androidx.compose.runtime.Stable -import com.wire.android.ui.home.conversations.model.messagetypes.asset.UIAssetMessage -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf +import androidx.paging.PagingData +import com.wire.android.ui.home.conversations.usecase.UIImageAssetPagingItem +import com.wire.android.ui.home.conversations.usecase.UIPagingItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow @Stable data class ConversationAssetMessagesViewState( - val messages: ImmutableList = persistentListOf() + val imageMessages: Flow> = emptyFlow(), + val assetMessages: Flow> = emptyFlow() ) 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 eab4f1e8a6..8a9e97744b 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 @@ -18,25 +18,54 @@ package com.wire.android.ui.home.conversations.media +import androidx.annotation.StringRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph import com.wire.android.R +import com.wire.android.media.audiomessage.AudioState import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.common.TabItem +import com.wire.android.ui.common.WireTabRow +import com.wire.android.ui.common.calculateCurrentTab import com.wire.android.ui.common.colorsScheme -import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.topBarElevation import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.destinations.MediaGalleryScreenDestination +import com.wire.android.ui.home.conversations.DownloadedAssetDialog +import com.wire.android.ui.home.conversations.MessageComposerViewModel +import com.wire.android.ui.home.conversations.SnackBarMessage +import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.id.ConversationId +import kotlinx.coroutines.launch @RootNavGraph @Destination( @@ -44,9 +73,13 @@ import com.wire.kalium.logic.data.id.ConversationId style = PopUpNavigationAnimation::class ) @Composable -fun ConversationMediaScreen(navigator: Navigator) { - val viewModel: ConversationAssetMessagesViewModel = hiltViewModel() - val state: ConversationAssetMessagesViewState = viewModel.viewState +fun ConversationMediaScreen( + navigator: Navigator, + conversationAssetMessagesViewModel: ConversationAssetMessagesViewModel = hiltViewModel(), + conversationMessagesViewModel: ConversationMessagesViewModel = hiltViewModel(), + messageComposerViewModel: MessageComposerViewModel = hiltViewModel() +) { + val state: ConversationAssetMessagesViewState = conversationAssetMessagesViewModel.viewState Content( state = state, @@ -63,37 +96,107 @@ fun ConversationMediaScreen(navigator: Navigator) { ) ) }, - continueAssetLoading = { shouldContinue -> - viewModel.continueLoading(shouldContinue) - } + onAssetItemClicked = conversationMessagesViewModel::downloadOrFetchAssetAndShowDialog, + audioMessagesState = conversationMessagesViewModel.conversationViewState.audioMessagesState, + onAudioItemClicked = conversationMessagesViewModel::audioClick, + ) + + DownloadedAssetDialog( + downloadedAssetDialogState = conversationMessagesViewModel.conversationViewState.downloadedAssetDialogState, + onSaveFileToExternalStorage = conversationMessagesViewModel::downloadAssetExternally, + onOpenFileWithExternalApp = conversationMessagesViewModel::downloadAndOpenAsset, + hideOnAssetDownloadedDialog = conversationMessagesViewModel::hideOnAssetDownloadedDialog + ) + + SnackBarMessage( + messageComposerViewModel.infoMessage, + conversationMessagesViewModel.infoMessage ) } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun Content( state: ConversationAssetMessagesViewState, onNavigationPressed: () -> Unit = {}, onImageFullScreenMode: (conversationId: ConversationId, messageId: String, isSelfAsset: Boolean) -> Unit, - continueAssetLoading: (shouldContinue: Boolean) -> Unit + audioMessagesState: Map = emptyMap(), + onAudioItemClicked: (String) -> Unit, + onAssetItemClicked: (String) -> Unit ) { + val scope = rememberCoroutineScope() + val lazyListStates: List = ConversationMediaScreenTabItem.entries.map { rememberLazyListState() } + val initialPageIndex = ConversationMediaScreenTabItem.PICTURES.ordinal + val pagerState = rememberPagerState(initialPage = initialPageIndex, pageCount = { ConversationMediaScreenTabItem.entries.size }) + val maxAppBarElevation = MaterialTheme.wireDimensions.topBarShadowElevation + val currentTabState by remember { derivedStateOf { pagerState.calculateCurrentTab() } } + val elevationState by remember { derivedStateOf { lazyListStates[currentTabState].topBarElevation(maxAppBarElevation) } } + WireScaffold( modifier = Modifier .background(color = colorsScheme().backgroundVariant), topBar = { WireCenterAlignedTopAppBar( - elevation = dimensions().spacing0x, + elevation = elevationState, title = stringResource(id = R.string.label_conversation_media), navigationIconType = NavigationIconType.Back, - onNavigationPressed = onNavigationPressed + onNavigationPressed = onNavigationPressed, + bottomContent = { + WireTabRow( + tabs = ConversationMediaScreenTabItem.entries, + selectedTabIndex = currentTabState, + onTabChange = { scope.launch { pagerState.animateScrollToPage(it) } } + ) + } ) }, ) { padding -> - // TODO implement tab here for https://wearezeta.atlassian.net/browse/WPB-5378 - AssetGrid( - uiAssetMessageList = state.messages, - modifier = Modifier.padding(padding), - onImageFullScreenMode = onImageFullScreenMode, - continueAssetLoading = continueAssetLoading + var focusedTabIndex: Int by remember { mutableStateOf(initialPageIndex) } + + CompositionLocalProvider(LocalOverscrollConfiguration provides null) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .padding(padding) + ) { pageIndex -> + when (ConversationMediaScreenTabItem.entries[pageIndex]) { + ConversationMediaScreenTabItem.PICTURES -> ImageAssetsContent( + imageMessageList = state.imageMessages, + onImageFullScreenMode = onImageFullScreenMode + ) + ConversationMediaScreenTabItem.FILES -> FileAssetsContent( + groupedAssetMessageList = state.assetMessages, + audioMessagesState = audioMessagesState, + onAudioItemClicked = onAudioItemClicked, + onAssetItemClicked = onAssetItemClicked + ) + } + } + + LaunchedEffect(pagerState.isScrollInProgress, focusedTabIndex, pagerState.currentPage) { + if (!pagerState.isScrollInProgress && focusedTabIndex != pagerState.currentPage) { + focusedTabIndex = pagerState.currentPage + } + } + } + } +} + +enum class ConversationMediaScreenTabItem(@StringRes override val titleResId: Int) : TabItem { + PICTURES(R.string.label_conversation_pictures), + FILES(R.string.label_conversation_files); +} + +@PreviewMultipleThemes +@Composable +fun previewConversationMediaScreenEmptyContent() { + WireTheme { + Content( + state = ConversationAssetMessagesViewState(), + onImageFullScreenMode = { _, _, _ -> }, + onAudioItemClicked = { }, + onAssetItemClicked = { } ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/EmptyMediaContentScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/EmptyMediaContentScreen.kt new file mode 100644 index 0000000000..e7e3eec92e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/EmptyMediaContentScreen.kt @@ -0,0 +1,80 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.media + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.WireTheme +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +fun EmptyMediaContentScreen( + text: String +) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(horizontal = dimensions().spacing48x), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = text, + style = MaterialTheme.wireTypography.body01, + textAlign = TextAlign.Center + ) + } + } +} + +@PreviewMultipleThemes +@Composable +fun previewAssetEmptyMediaContentScreen() { + WireTheme { + EmptyMediaContentScreen( + text = stringResource(R.string.label_conversation_files_empty) + ) + } +} + +@PreviewMultipleThemes +@Composable +fun previewPictureEmptyMediaContentScreen() { + WireTheme { + EmptyMediaContentScreen( + text = stringResource(R.string.label_conversation_pictures_empty) + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt new file mode 100644 index 0000000000..ca0511b980 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt @@ -0,0 +1,145 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.media + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.paging.LoadState +import androidx.paging.PagingData +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemContentType +import androidx.paging.compose.itemKey +import com.wire.android.R +import com.wire.android.media.audiomessage.AudioState +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.progress.WireCircularProgressIndicator +import com.wire.android.ui.home.conversations.MessageItem +import com.wire.android.ui.home.conversations.info.ConversationDetailsData +import com.wire.android.ui.home.conversations.model.UIMessage +import com.wire.android.ui.home.conversations.usecase.UIPagingItem +import com.wire.android.ui.home.conversationslist.common.FolderHeader +import com.wire.android.ui.theme.wireColorScheme +import kotlinx.coroutines.flow.Flow + +@Composable +fun FileAssetsContent( + groupedAssetMessageList: Flow>, + audioMessagesState: Map = emptyMap(), + onAudioItemClicked: (String) -> Unit, + onAssetItemClicked: (String) -> Unit +) { + val lazyPagingMessages = groupedAssetMessageList.collectAsLazyPagingItems() + + if (lazyPagingMessages.itemCount > 0) { + AssetMessagesListContent( + groupedAssetMessageList = lazyPagingMessages, + audioMessagesState = audioMessagesState, + onAudioItemClicked = onAudioItemClicked, + onAssetItemClicked = onAssetItemClicked + ) + } else { + EmptyMediaContentScreen( + text = stringResource(R.string.label_conversation_files_empty) + ) + } +} + +@Composable +private fun AssetMessagesListContent( + groupedAssetMessageList: LazyPagingItems, + audioMessagesState: Map, + onAudioItemClicked: (String) -> Unit, + onAssetItemClicked: (String) -> Unit, +) { + LazyColumn { + items( + count = groupedAssetMessageList.itemCount, + key = groupedAssetMessageList.itemKey { + when (it) { + is UIPagingItem.Label -> it.date + is UIPagingItem.Message -> it.uiMessage.header.messageId + } + }, + contentType = groupedAssetMessageList.itemContentType { it } + ) { index -> + val uiPagingItem: UIPagingItem = groupedAssetMessageList[index] ?: return@items + + when (uiPagingItem) { + is UIPagingItem.Label -> Box( + modifier = Modifier + .fillMaxWidth() + .padding( + bottom = dimensions().spacing6x, + // first label should not have top padding + top = if (index == 0) dimensions().spacing0x else dimensions().spacing6x, + ) + ) { + FolderHeader( + name = uiPagingItem.date.uppercase(), + modifier = Modifier + .background(MaterialTheme.wireColorScheme.background) + .fillMaxWidth() + ) + } + + is UIPagingItem.Message -> { + when (val message = uiPagingItem.uiMessage) { + is UIMessage.Regular -> { + MessageItem( + message = message, + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = audioMessagesState, + onAudioClick = onAudioItemClicked, + onChangeAudioPosition = { _, _ -> }, + onLongClicked = { }, + onAssetMessageClicked = onAssetItemClicked, + onImageMessageClicked = { _, _ -> }, + onOpenProfile = { _ -> }, + onReactionClicked = { _, _ -> }, + onResetSessionClicked = { _, _ -> }, + onSelfDeletingMessageRead = { }, + defaultBackgroundColor = colorsScheme().backgroundVariant, + shouldDisplayMessageStatus = false, + shouldDisplayFooter = false, + onLinkClick = { } + ) + } + + is UIMessage.System -> {} + } + } + } + } + item { + if (groupedAssetMessageList.loadState.append is LoadState.Loading) { + WireCircularProgressIndicator( + progressColor = MaterialTheme.wireColorScheme.onBackground, + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ImageAssetsContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ImageAssetsContent.kt index ed8fe782bb..1e568a8d57 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ImageAssetsContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ImageAssetsContent.kt @@ -28,52 +28,61 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.paging.PagingData +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemContentType +import com.wire.android.R 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.model.MediaAssetImage import com.wire.android.ui.home.conversations.model.messagetypes.asset.UIAssetMessage +import com.wire.android.ui.home.conversations.usecase.UIImageAssetPagingItem import com.wire.android.ui.home.conversationslist.common.FolderHeader +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.util.map.forEachIndexed +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.message.Message +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.datetime.Instant -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime -import java.time.format.TextStyle -import java.util.Locale @Composable -fun AssetGrid( - uiAssetMessageList: List, - modifier: Modifier = Modifier, - onImageFullScreenMode: (conversationId: ConversationId, messageId: String, isSelfAsset: Boolean) -> Unit, - continueAssetLoading: (shouldContinue: Boolean) -> Unit +fun ImageAssetsContent( + imageMessageList: Flow>, + onImageFullScreenMode: (conversationId: ConversationId, messageId: String, isSelfAsset: Boolean) -> Unit ) { - val timeZone = remember { TimeZone.currentSystemDefault() } - val groupedAssets = remember(uiAssetMessageList) { groupAssetsByMonthYear(uiAssetMessageList, timeZone) } - val scrollState = rememberLazyGridState() - val shouldContinue by remember { - derivedStateOf { - !scrollState.canScrollForward - } - } + val lazyPagingMessages = imageMessageList.collectAsLazyPagingItems() - // act when end of list reached - LaunchedEffect(shouldContinue) { - continueAssetLoading(shouldContinue) + if (lazyPagingMessages.itemCount > 0) { + ImageAssetGrid( + uiAssetMessageList = lazyPagingMessages, + onImageFullScreenMode = onImageFullScreenMode + ) + } else { + EmptyMediaContentScreen( + text = stringResource(R.string.label_conversation_pictures_empty) + ) } +} +@Composable +private fun ImageAssetGrid( + uiAssetMessageList: LazyPagingItems, + modifier: Modifier = Modifier, + onImageFullScreenMode: (conversationId: ConversationId, messageId: String, isSelfAsset: Boolean) -> Unit +) { BoxWithConstraints( modifier .fillMaxSize() @@ -88,82 +97,116 @@ fun AssetGrid( LazyVerticalGrid( columns = GridCells.Fixed(COLUMN_COUNT), - state = scrollState, - horizontalArrangement = Arrangement.SpaceEvenly, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.wireDimensions.spacing2x), ) { - groupedAssets.forEachIndexed { index, entry -> - val label = entry.key - item( - key = entry.key, - span = { GridItemSpan(COLUMN_COUNT) }) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding( - bottom = dimensions().spacing6x, - // first label should not have top padding - top = if (index == 0) dimensions().spacing0x else dimensions().spacing6x, - ) - ) { - FolderHeader( - name = label.uppercase(), - modifier = Modifier - .background(MaterialTheme.wireColorScheme.background) - .fillMaxWidth() - ) + items( + count = uiAssetMessageList.itemCount, + key = { + when (val item = uiAssetMessageList[it]) { + is UIImageAssetPagingItem.Asset -> item.uiAssetMessage.assetId + is UIImageAssetPagingItem.Label -> item.date + null -> "$it" + } + }, + contentType = uiAssetMessageList.itemContentType { it }, + span = { index -> + when (uiAssetMessageList[index]) { + is UIImageAssetPagingItem.Asset -> GridItemSpan(1) + is UIImageAssetPagingItem.Label -> GridItemSpan(COLUMN_COUNT) + null -> GridItemSpan(1) } } - - items( - count = entry.value.size, - key = { entry.value[it].assetId } - ) { - val uiAsset = entry.value[it] - val currentOnImageClick = remember(uiAsset) { - Clickable(enabled = true, onClick = { - onImageFullScreenMode( - uiAsset.conversationId, uiAsset.messageId, uiAsset.isSelfAsset + ) { index -> + when (val uiImageAssetPagingItem = uiAssetMessageList[index]) { + is UIImageAssetPagingItem.Asset -> { + val uiAsset = uiImageAssetPagingItem.uiAssetMessage + val currentOnImageClick = remember(uiAsset) { + Clickable(enabled = true, onClick = { + onImageFullScreenMode( + uiAsset.conversationId, uiAsset.messageId, uiAsset.isSelfAsset + ) + }) + } + Box( + modifier = Modifier + .padding(all = dimensions().spacing2x) + ) { + MediaAssetImage( + asset = null, + width = itemSize, + height = itemSize, + downloadStatus = uiAsset.downloadStatus, + onImageClick = currentOnImageClick, + assetPath = uiAsset.assetPath ) - }) + } } - Box( - modifier = Modifier - .padding(all = dimensions().spacing2x) - ) { - MediaAssetImage( - asset = null, - width = itemSize, - height = itemSize, - downloadStatus = uiAsset.downloadStatus, - onImageClick = currentOnImageClick, - assetPath = uiAsset.assetPath - ) + + is UIImageAssetPagingItem.Label -> { + val label = uiImageAssetPagingItem.date + Box( + modifier = Modifier + .fillMaxWidth() + .padding( + bottom = dimensions().spacing6x, + // first label should not have top padding + top = if (index == 0) dimensions().spacing0x else dimensions().spacing6x, + ) + ) { + FolderHeader( + name = label.uppercase(), + modifier = Modifier + .background(MaterialTheme.wireColorScheme.background) + .fillMaxWidth() + ) + } } + + null -> {} } } } } } -fun monthYearHeader(month: Int, year: Int): String { - val currentYear = Instant.fromEpochMilliseconds(System.currentTimeMillis()).toLocalDateTime(TimeZone.currentSystemDefault()).year - val monthYearInstant = LocalDateTime(year = year, monthNumber = month, 1, 0, 0, 0) +private const val COLUMN_COUNT = 3 - val monthName = monthYearInstant.month.getDisplayName(TextStyle.FULL_STANDALONE, Locale.getDefault()) - return if (year == currentYear) { - // If it's the current year, display only the month name - monthName - } else { - // If it's not the current year, display both the month name and the year - "$monthName $year" - } -} - -fun groupAssetsByMonthYear(uiAssetMessageList: List, timeZone: TimeZone): Map> { - return uiAssetMessageList.groupBy { asset -> - val localDateTime = asset.time.toLocalDateTime(timeZone) - monthYearHeader(year = localDateTime.year, month = localDateTime.monthNumber) +@PreviewMultipleThemes +@Composable +fun previewAssetGrid() { + val message1 = UIAssetMessage( + assetId = "1", + time = Instant.DISTANT_PAST, + username = UIText.DynamicString("Username 1"), + messageId = "msg1", + conversationId = QualifiedID("value", "domain"), + assetPath = null, + downloadStatus = Message.DownloadStatus.SAVED_EXTERNALLY, + isSelfAsset = false + ) + val message2 = message1.copy( + messageId = "msg2", + username = UIText.DynamicString("Username 2"), + downloadStatus = Message.DownloadStatus.NOT_DOWNLOADED, + isSelfAsset = true + ) + val message3 = message2.copy( + messageId = "msg3", + downloadStatus = Message.DownloadStatus.DOWNLOAD_IN_PROGRESS, + ) + WireTheme { + ImageAssetGrid( + uiAssetMessageList = flowOf( + PagingData.from( + listOf( + UIImageAssetPagingItem.Label("October"), + UIImageAssetPagingItem.Asset(message1), + UIImageAssetPagingItem.Asset(message2), + UIImageAssetPagingItem.Asset(message3), + ) + ) + ).collectAsLazyPagingItems(), + onImageFullScreenMode = { _, _, _ -> } + ) } } - -private const val COLUMN_COUNT = 4 diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/image/ImageMessageTypes.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/image/ImageMessageTypes.kt index a3597fe59f..a3ddcc1af4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/image/ImageMessageTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/image/ImageMessageTypes.kt @@ -28,6 +28,7 @@ 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.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -83,12 +84,13 @@ fun AsyncImageMessage( .width(width) .height(height), loading = { _ -> - WireCircularProgressIndicator( - progressColor = MaterialTheme.wireColorScheme.primary, - modifier = Modifier.align( - Alignment.Center - ).padding(dimensions().spacing24x) - ) + Box(modifier = Modifier.size(MaterialTheme.wireDimensions.spacing24x), + contentAlignment = Alignment.Center) { + WireCircularProgressIndicator( + progressColor = MaterialTheme.wireColorScheme.primary, + modifier = Modifier.padding(dimensions().spacing24x) + ) + } }, alignment = Alignment.Center, contentScale = ContentScale.Crop diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetAssetMessagesFromConversationUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetAssetMessagesFromConversationUseCase.kt new file mode 100644 index 0000000000..0ea4b8e29b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetAssetMessagesFromConversationUseCase.kt @@ -0,0 +1,115 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.usecase + +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.flatMap +import androidx.paging.insertSeparators +import com.wire.android.mapper.MessageMapper +import com.wire.android.ui.common.monthYearHeader +import com.wire.android.ui.home.conversations.model.UIMessage +import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.feature.asset.GetPaginatedFlowOfAssetMessageByConversationIdUseCase +import com.wire.kalium.logic.feature.conversation.ObserveUserListByIdUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import javax.inject.Inject +import kotlin.math.max + +class GetAssetMessagesFromConversationUseCase @Inject constructor( + private val getAssetMessages: GetPaginatedFlowOfAssetMessageByConversationIdUseCase, + private val observeMemberDetailsByIds: ObserveUserListByIdUseCase, + private val messageMapper: MessageMapper, + private val dispatchers: DispatcherProvider +) { + + /** + * This operation combines asset messages from a conversation and its respective user to UI + * @param conversationId The conversation ID that it will look for asset messages in. + * + * @return A [PagingData>] indicating the success of the operation. + */ + suspend operator fun invoke( + conversationId: ConversationId, + initialOffset: Int + ): Flow> { + val pagingConfig = PagingConfig( + pageSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, + initialLoadSize = INITIAL_LOAD_SIZE + ) + + return getAssetMessages( + conversationId = conversationId, + startingOffset = max(0, initialOffset - PREFETCH_DISTANCE).toLong(), + pagingConfig = pagingConfig + ).map { pagingData -> + val currentTime = TimeZone.currentSystemDefault() + val uiMessagePagingData: PagingData = pagingData.flatMap { messageItem -> + observeMemberDetailsByIds(messageMapper.memberIdList(listOf(messageItem))) + .mapLatest { usersList -> + messageMapper.toUIMessage(usersList, messageItem) + ?.let { listOf(UIPagingItem.Message(it, Instant.parse(messageItem.date))) } + ?: emptyList() + }.first() + }.insertSeparators { before: UIPagingItem.Message?, after: UIPagingItem.Message? -> + if (before == null && after != null) { + val localDateTime = after.date.toLocalDateTime(currentTime) + UIPagingItem.Label(monthYearHeader(year = localDateTime.year, month = localDateTime.monthNumber)) + } else if (before != null && after != null) { + val beforeDateTime = before.date.toLocalDateTime(currentTime) + val afterDateTime = after.date.toLocalDateTime(currentTime) + + if (beforeDateTime.year != afterDateTime.year + || beforeDateTime.month != afterDateTime.month + ) { + UIPagingItem.Label(monthYearHeader(year = afterDateTime.year, month = afterDateTime.monthNumber)) + } else { + null + } + } else { + // no separator - either end of list, or first + // letters of items are the same + null + } + } + uiMessagePagingData + }.flowOn(dispatchers.io()) + } + + private companion object { + const val PAGE_SIZE = 20 + const val INITIAL_LOAD_SIZE = 20 + const val PREFETCH_DISTANCE = 30 + } +} + +sealed class UIPagingItem { + + data class Message(val uiMessage: UIMessage, val date: Instant) : UIPagingItem() + + data class Label(val date: String) : UIPagingItem() +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCase.kt new file mode 100644 index 0000000000..31253422f5 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCase.kt @@ -0,0 +1,103 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.usecase + +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.insertSeparators +import androidx.paging.map +import com.wire.android.mapper.UIAssetMapper +import com.wire.android.ui.common.monthYearHeader +import com.wire.android.ui.home.conversations.model.messagetypes.asset.UIAssetMessage +import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.time.TimeZoneProvider +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.feature.asset.ObservePaginatedAssetImageMessages +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.datetime.toLocalDateTime +import javax.inject.Inject +import kotlin.math.max + +class ObserveImageAssetMessagesFromConversationUseCase @Inject constructor( + private val getAssetMessages: ObservePaginatedAssetImageMessages, + private val assetMapper: UIAssetMapper, + private val dispatchers: DispatcherProvider, + private val timeZoneProvider: TimeZoneProvider +) { + + /** + * This operation observers image asset messages from a conversation + * @param conversationId The conversation ID that it will look for image asset messages in. + * + * @return A [PagingData>] containing [UIImageAssetPagingItem.Asset] and time [UIImageAssetPagingItem.Label]. + */ + suspend operator fun invoke( + conversationId: ConversationId, + initialOffset: Int + ): Flow> { + val pagingConfig = PagingConfig( + pageSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, + initialLoadSize = INITIAL_LOAD_SIZE + ) + + return getAssetMessages( + conversationId = conversationId, + startingOffset = max(0, initialOffset - PREFETCH_DISTANCE).toLong(), + pagingConfig = pagingConfig + ).map { pagingData -> + val currentTime = timeZoneProvider.currentSystemDefault() + pagingData.map { assetMessage -> + UIImageAssetPagingItem.Asset(assetMapper.toUIAsset(assetMessage)) + }.insertSeparators { before: UIImageAssetPagingItem.Asset?, after: UIImageAssetPagingItem.Asset? -> + if (before == null && after != null) { + val localDateTime = after.uiAssetMessage.time.toLocalDateTime(currentTime) + UIImageAssetPagingItem.Label(monthYearHeader(year = localDateTime.year, month = localDateTime.monthNumber)) + } else if (before != null && after != null) { + val beforeDateTime = before.uiAssetMessage.time.toLocalDateTime(currentTime) + val afterDateTime = after.uiAssetMessage.time.toLocalDateTime(currentTime) + + if (beforeDateTime.year != afterDateTime.year + || beforeDateTime.month != afterDateTime.month + ) { + UIImageAssetPagingItem.Label(monthYearHeader(year = afterDateTime.year, month = afterDateTime.monthNumber)) + } else { + null + } + } else { + null + } + } + }.flowOn(dispatchers.io()) + } + + private companion object { + const val PAGE_SIZE = 20 + const val INITIAL_LOAD_SIZE = 20 + const val PREFETCH_DISTANCE = 30 + } +} + +sealed class UIImageAssetPagingItem { + + data class Asset(val uiAssetMessage: UIAssetMessage) : UIImageAssetPagingItem() + + data class Label(val date: String) : UIImageAssetPagingItem() +} diff --git a/app/src/main/kotlin/com/wire/android/util/time/TimeZoneProvider.kt b/app/src/main/kotlin/com/wire/android/util/time/TimeZoneProvider.kt new file mode 100644 index 0000000000..0e3dbfd21f --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/time/TimeZoneProvider.kt @@ -0,0 +1,25 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util.time + +import kotlinx.datetime.TimeZone +import javax.inject.Inject + +class TimeZoneProvider @Inject constructor() { + fun currentSystemDefault(): TimeZone = TimeZone.currentSystemDefault() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 140bc7ce50..2f8f20ab22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -725,7 +725,8 @@ Media Pictures Files - Links + No pictures have been shared in this conversation yet 🥲 + No files have been shared in this conversation yet 🙀 CONTACTS New Group diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCaseTest.kt new file mode 100644 index 0000000000..1ae6c50366 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCaseTest.kt @@ -0,0 +1,120 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.usecase + +import androidx.paging.PagingData +import androidx.paging.testing.asSnapshot +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.TestDispatcherProvider +import com.wire.android.mapper.UIAssetMapper +import com.wire.android.util.time.TimeZoneProvider +import com.wire.kalium.logic.data.asset.AssetMessage +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.message.Message +import com.wire.kalium.logic.feature.asset.ObservePaginatedAssetImageMessages +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import okio.Path.Companion.toPath +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class) +class ObserveImageAssetMessagesFromConversationUseCaseTest { + + @Test + fun `given asset messages across months, when use case is invoked, then date separators are inserted correctly`() = runTest { + // Given + val (_, useCase) = Arrangement() + .sendPagingData( + listOf( + assetMessage("asset1", Instant.parse("2023-10-12T10:00:00.671Z")), + assetMessage( + "asset2", Instant.parse("2023-11-12T10:00:00.671Z") + ) + ) + ) + .arrange() + + // When + val result = useCase(conversationId = ConversationId("test-conversation-id", "test-domain"), initialOffset = 0) + + // Then + val items: List = result.asSnapshot() + + assertInstanceOf(UIImageAssetPagingItem.Label::class.java, items[0]) + assertInstanceOf(UIImageAssetPagingItem.Asset::class.java, items[1]) + assertInstanceOf(UIImageAssetPagingItem.Label::class.java, items[2]) + assertInstanceOf(UIImageAssetPagingItem.Asset::class.java, items[3]) + } + + private class Arrangement { + + @MockK + lateinit var getAssetMessages: ObservePaginatedAssetImageMessages + + @MockK + lateinit var timeZoneProvider: TimeZoneProvider + + private val useCase: ObserveImageAssetMessagesFromConversationUseCase by lazy { + ObserveImageAssetMessagesFromConversationUseCase( + getAssetMessages, UIAssetMapper(), TestDispatcherProvider(), timeZoneProvider, + ) + } + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { timeZoneProvider.currentSystemDefault() } returns TimeZone.of("UTC") + } + + suspend fun sendPagingData(pagingItems: List) = apply { + coEvery { + getAssetMessages( + any(), + any(), + any() + ) + } returns flowOf(PagingData.from(pagingItems)) + } + + fun arrange() = this to useCase + } + + companion object { + fun assetMessage(assetId: String, time: Instant = Instant.parse("2023-11-12T10:00:00.671Z")) = AssetMessage( + time = time, + conversationId = ConversationId("value", "domain"), + username = "username", + messageId = "messageId", + assetId = assetId, + width = 640, + height = 480, + downloadStatus = Message.DownloadStatus.SAVED_INTERNALLY, + assetPath = "asset/path".toPath(), + isSelfAsset = false + ) + } +} diff --git a/kalium b/kalium index d911835d83..10e2eec72c 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit d911835d838056c4de0cbbfa99cdda4b6b93527c +Subproject commit 10e2eec72c639bd680db417f2c778266dd4520ba