diff --git a/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt index 222de95d683..fe5457f6e19 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt @@ -31,7 +31,6 @@ import com.wire.android.ui.home.conversations.model.messagetypes.image.VisualMed import com.wire.android.ui.markdown.toMarkdownDocument import com.wire.android.ui.markdown.toMarkdownTextWithMentions import com.wire.android.ui.theme.Accent -import com.wire.android.util.getVideoMetaData import com.wire.android.util.time.ISOFormatter import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.asset.AttachmentType @@ -297,12 +296,11 @@ class RegularMessageMapper @Inject constructor( } assetMessageContentMetadata.isVideo() -> { - val metaData = localData?.assetDataPath?.let { getVideoMetaData(it) } + val metaData = metadata as? AssetContent.AssetMetadata.Video UIMessageContent.VideoMessage( assetName = name ?: "", assetExtension = name?.split(".")?.last() ?: "", assetId = AssetId(remoteData.assetId, remoteData.assetDomain.orEmpty()), - assetDataPath = localData?.assetDataPath, assetSizeInBytes = sizeInBytes, deliveryStatus = mapRecipientsFailure(userList, deliveryStatus), params = VisualMediaParams(metaData?.width ?: 0, metaData?.height ?: 0), @@ -317,7 +315,6 @@ class RegularMessageMapper @Inject constructor( assetExtension = name?.split(".")?.last() ?: "", assetId = AssetId(remoteData.assetId, remoteData.assetDomain.orEmpty()), assetSizeInBytes = sizeInBytes, - assetDataPath = localData?.assetDataPath, deliveryStatus = mapRecipientsFailure(userList, deliveryStatus) ) } 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 89c43885659..2c399b057ea 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 @@ -39,6 +39,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentMap import javax.inject.Inject @ViewModelScopedPreview @@ -58,7 +60,9 @@ class AudioMessageViewModelImpl @Inject constructor( private val args: AudioMessageArgs = savedStateHandle.scopedArgs() - override var state: AudioMessageState by mutableStateOf(AudioMessageState()) + override var state: AudioMessageState by mutableStateOf( + AudioMessageState(wavesMask = cachedWavesMasks[args.key]) + ) private set init { @@ -92,9 +96,15 @@ class AudioMessageViewModelImpl @Inject constructor( } private fun preloadAudioMessage() { + if (preloadStates.putIfAbsent(args.key, PreloadState.InFlight) != null) return viewModelScope.launch { - // calls preload to initially fetch the audio asset data to be ready and schedule waves mask generation if needed - audioMessagePlayer.preloadAudioMessage(args.conversationId, args.messageId) + try { + // calls preload to initially fetch the audio asset data to be ready and schedule waves mask generation if needed + audioMessagePlayer.preloadAudioMessage(args.conversationId, args.messageId) + preloadStates[args.key] = PreloadState.Succeeded + } catch (_: Throwable) { + preloadStates.remove(args.key, PreloadState.InFlight) + } } } @@ -110,7 +120,10 @@ class AudioMessageViewModelImpl @Inject constructor( } .distinctUntilChanged() .firstOrNull { it != null } // wait for the first non-null value - .let { state = state.copy(wavesMask = it) } + ?.let { + cachedWavesMasks[args.key] = it + state = state.copy(wavesMask = it) + } } } @@ -131,6 +144,16 @@ class AudioMessageViewModelImpl @Inject constructor( audioMessagePlayer.setSpeed(audioSpeed) } } + + private companion object { + val cachedWavesMasks = ConcurrentHashMap>() + val preloadStates: ConcurrentMap = ConcurrentHashMap() + } + + private enum class PreloadState { + InFlight, + Succeeded, + } } @Serializable diff --git a/app/src/main/kotlin/com/wire/android/ui/common/multipart/MultipartAttachmentUi.kt b/app/src/main/kotlin/com/wire/android/ui/common/multipart/MultipartAttachmentUi.kt index a96181ddbb6..48eb23aba01 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/multipart/MultipartAttachmentUi.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/multipart/MultipartAttachmentUi.kt @@ -73,7 +73,7 @@ fun AssetContent.toUiModel(progress: Float?) = MultipartAttachmentUi( uuid = this.remoteData.assetId, source = AssetSource.ASSET_STORAGE, fileName = this.name, - localPath = this.localData?.assetDataPath, + localPath = null, previewUrl = null, mimeType = this.mimeType, assetType = AttachmentFileType.fromMimeType(mimeType), 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 8cb424fc127..33a027db344 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 @@ -15,6 +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/. */ +@file:Suppress("TooManyFunctions") package com.wire.android.ui.home.conversations @@ -28,11 +29,13 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.stopScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -1257,6 +1260,8 @@ fun MessageList( val readLastMessageAtStartTriggered = remember { mutableStateOf(false) } val shouldTriggerOldestMessageFetch = remember { mutableStateOf(true) } val currentTime by currentTimeInMillisFlow.collectAsState(initial = System.currentTimeMillis()) + val isPrependLoading = lazyPagingMessages.loadState.prepend is LoadState.Loading + val isPrependCompleted = lazyPagingMessages.loadState.prepend.endOfPaginationReached LaunchedEffect(lazyPagingMessages.itemCount) { if (lazyPagingMessages.itemCount > prevItemCount.value && selectedMessageId == null) { @@ -1264,6 +1269,7 @@ fun MessageList( && lazyListState.firstVisibleItemIndex > 0 && lazyListState.firstVisibleItemIndex <= MAXIMUM_SCROLLED_MESSAGES_UNTIL_AUTOSCROLL_STOPS if (canScrollToLastMessage) { + lazyListState.stopScroll() lazyListState.animateScrollToItem(0) } prevItemCount.value = lazyPagingMessages.itemCount @@ -1328,20 +1334,19 @@ fun MessageList( items( count = lazyPagingMessages.itemCount, key = lazyPagingMessages.itemKey { it.header.messageId }, - contentType = lazyPagingMessages.itemContentType { it } + contentType = lazyPagingMessages.itemContentType { message -> + when (message) { + is UIMessage.Regular -> "regular_message" + is UIMessage.System -> "system_message" + } + } ) { index -> val message: UIMessage = lazyPagingMessages[index] - ?: return@items Box( - contentAlignment = Alignment.Center, + ?: return@items Spacer( modifier = Modifier .fillMaxWidth() - .height(dimensions().spacing56x), - ) { - WireCircularProgressIndicator( - progressColor = MaterialTheme.wireColorScheme.secondaryText, - size = dimensions().spacing24x - ) - } + .height(dimensions().spacing56x) + ) val showAuthor = if (!isBubbleUiEnabled || conversationDetailsData is ConversationDetailsData.Group) { rememberShouldShowHeader(index, message, lazyPagingMessages) @@ -1370,8 +1375,8 @@ fun MessageList( } } - val swipeableConfiguration = remember(message) { - if (message is UIMessage.Regular && message.isSwipeable) { + val swipeableConfiguration = remember(message, lazyListState.isScrollInProgress) { + if (!lazyListState.isScrollInProgress && message is UIMessage.Regular && message.isSwipeable) { SwipeableMessageConfiguration.Swipeable( onSwipedRight = { onSwipedToReply(message) }.takeIf { message.isReplyable }, onSwipedLeft = { onSwipedToReact(message) }.takeIf { message.isReactionAllowed }, @@ -1411,7 +1416,9 @@ fun MessageList( } } // reverse layout, so prepend needs to be added after all messages to be displayed at the top of the list - if (showHistoryLoadingIndicator && lazyPagingMessages.itemCount > 0) { + if (lazyPagingMessages.itemCount > 0 && + (isPrependLoading || (showHistoryLoadingIndicator && isPrependCompleted)) + ) { item( key = "prepend_loading_indicator", contentType = "prepend_loading_indicator", @@ -1422,30 +1429,34 @@ fun MessageList( .fillMaxWidth() .padding(dimensions().spacing16x), ) { - var allMessagesPrepended by remember { mutableStateOf(false) } - LaunchedEffect(lazyPagingMessages.loadState) { - // When the list is being refreshed, the load state for prepend is cleared so the app doesn't know if - // the end of pagination is reached or not for prepend until the refresh is done, so we don't want to - // update the allMessagesPrepended state while refreshing and in that case keep the last updated state, - // otherwise the indicator will flicker while refreshing. - if (lazyPagingMessages.loadState.refresh is LoadState.NotLoading) { - allMessagesPrepended = lazyPagingMessages.loadState.prepend.endOfPaginationReached - } + val (text, prefixIconResId) = when { + showHistoryLoadingIndicator && isPrependCompleted -> + stringResource(R.string.conversation_history_loaded) to null + showHistoryLoadingIndicator -> + stringResource(R.string.conversation_history_loading) to R.drawable.ic_undo + else -> "" to null } - val (text, prefixIconResId) = when (allMessagesPrepended) { - true -> stringResource(R.string.conversation_history_loaded) to null - false -> stringResource(R.string.conversation_history_loading) to R.drawable.ic_undo + if (showHistoryLoadingIndicator) { + PageLoadingIndicator( + text = text, + prefixIconResId = prefixIconResId, + ) + } else { + WireCircularProgressIndicator( + progressColor = MaterialTheme.wireColorScheme.secondaryText, + size = dimensions().spacing24x + ) } - PageLoadingIndicator( - text = text, - prefixIconResId = prefixIconResId, - ) } } ) } } + ScrollDateOverlay( + lazyListState = lazyListState, + lazyPagingMessages = lazyPagingMessages + ) JumpToPlayingAudioButton( lazyListState = lazyListState, lazyPagingMessages = lazyPagingMessages, @@ -1456,6 +1467,62 @@ fun MessageList( ) } +@Composable +private fun BoxScope.ScrollDateOverlay( + lazyListState: LazyListState, + lazyPagingMessages: LazyPagingItems, +) { + val context = LocalContext.current + val dateLabel by remember(lazyListState, lazyPagingMessages) { + derivedStateOf { + if (!lazyListState.isScrollInProgress) return@derivedStateOf null + + val visibleItems = lazyListState.layoutInfo.visibleItemsInfo + var message: UIMessage? = null + for (index in visibleItems.lastIndex downTo 0) { + message = lazyPagingMessages.peekOrNull(visibleItems[index].index) + if (message != null) break + } + message ?: return@derivedStateOf null + val messageDate = message.header.messageTime.utcISO.serverDate() ?: return@derivedStateOf null + + DateUtils.formatDateTime( + context, + messageDate.time, + DateUtils.FORMAT_SHOW_WEEKDAY or DateUtils.FORMAT_SHOW_DATE + ) + } + } + + AnimatedVisibility( + visible = dateLabel != null, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.wireColorScheme.surface.copy(alpha = 0.8f) + ) + .padding(vertical = dimensions().spacing2x, horizontal = dimensions().spacing4x), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = dateLabel.orEmpty(), + style = MaterialTheme.wireTypography.title03, + color = MaterialTheme.wireColorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +private fun LazyPagingItems.peekOrNull(index: Int): UIMessage? = + if (index in 0 until itemCount) peek(index) else null + @Composable private fun MessageGroupDateTime( now: Long, @@ -1583,7 +1650,12 @@ fun JumpToLastMessageButton( exit = scaleOut(), ) { SmallFloatingActionButton( - onClick = { coroutineScope.launch { lazyListState.animateScrollToItem(0) } }, + onClick = { + coroutineScope.launch { + lazyListState.stopScroll() + lazyListState.animateScrollToItem(0) + } + }, containerColor = MaterialTheme.wireColorScheme.secondaryText, contentColor = MaterialTheme.wireColorScheme.onPrimaryButtonEnabled, shape = CircleShape, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index 944ff37303d..9733b0936be 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -84,7 +84,6 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okio.Path -import okio.Path.Companion.toPath import javax.inject.Inject import kotlin.math.max import kotlin.time.Duration.Companion.seconds @@ -258,10 +257,8 @@ class ConversationMessagesViewModel @Inject constructor( fun openOrFetchAsset(messageId: String) = viewModelScope.launch(dispatchers.io()) { if (isCellEnabledForConversation) { - val asset = getMessageByIdUseCase(conversationId, messageId).getAssetContent() - - asset?.localAssetPath()?.let { - onOpenFileWithExternalApp(it.toPath(), asset.value.name) + assetDataPath(conversationId, messageId)?.let { (path, assetName) -> + onOpenFileWithExternalApp(path, assetName) } ?: run { attemptDownloadOfAsset(messageId) } @@ -443,11 +440,4 @@ class ConversationMessagesViewModel @Inject constructor( } } -private fun GetMessageByIdUseCase.Result.getAssetContent(): MessageContent.Asset? = when (this) { - is GetMessageByIdUseCase.Result.Success -> this.message.content as? MessageContent.Asset - else -> null -} - -private fun MessageContent.Asset.localAssetPath(): String? = value.localData?.assetDataPath - private fun ConversationDetails.isWireCellEnabled() = (this as? ConversationDetails.Group)?.wireCell != null 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 f2abf99cfc6..9f15b0ec313 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 @@ -39,7 +39,12 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -52,7 +57,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension +import coil3.compose.SubcomposeAsyncImage import com.wire.android.R +import com.wire.android.di.hiltViewModelScoped import com.wire.android.model.Clickable import com.wire.android.model.ImageAsset import com.wire.android.ui.common.StatusBox @@ -62,6 +69,9 @@ 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.AssetLocalPathArgs +import com.wire.android.ui.home.conversations.messages.item.AssetLocalPathViewModel +import com.wire.android.ui.home.conversations.messages.item.AssetLocalPathViewModelImpl 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.textColor @@ -75,7 +85,9 @@ import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.data.asset.AssetTransferStatus import com.wire.kalium.logic.data.id.ConversationId +import okio.Path.Companion.toPath private const val TEXT_QUOTE_MAX_LINES = 7 @@ -493,15 +505,12 @@ private fun QuotedImage( style = style, modifier = modifier, endContent = { - Image( - painter = asset.paint(), - contentDescription = stringResource(R.string.content_description_image_message), + QuotedImageThumbnail( + asset = asset, modifier = Modifier .width(imageDimension) .height(imageDimension) - .clip(RoundedCornerShape(dimensions().spacing8x)), - alignment = Alignment.Center, - contentScale = ContentScale.Crop + .clip(RoundedCornerShape(dimensions().spacing8x)) ) }, startContent = { @@ -592,9 +601,8 @@ private fun AutosizeContainer( ) { content() } - Image( - painter = asset.paint(), - contentDescription = stringResource(R.string.content_description_image_message), + QuotedImageThumbnail( + asset = asset, modifier = Modifier .constrainAs(rightSide) { top.linkTo(leftSide.top) @@ -604,14 +612,58 @@ private fun AutosizeContainer( height = Dimension.fillToConstraints } .clip(RoundedCornerShape(dimensions().spacing8x)) - .border( - width = 1.dp, - color = MaterialTheme.wireColorScheme.outline, - shape = RoundedCornerShape(dimensions().spacing8x) - ), + ) + } +} + +@Composable +private fun QuotedImageThumbnail( + asset: ImageAsset.PrivateAsset, + modifier: Modifier = Modifier +) { + val viewModel: AssetLocalPathViewModel = + hiltViewModelScoped( + AssetLocalPathArgs(asset.conversationId, asset.messageId) + ) + var rememberedAssetDataPath by rememberSaveable(asset.uniqueKey) { + mutableStateOf(null) + } + + LaunchedEffect(viewModel.localAssetPath) { + if (viewModel.localAssetPath != null) { + rememberedAssetDataPath = viewModel.localAssetPath + } + } + + LaunchedEffect(rememberedAssetDataPath) { + viewModel.resolveIfNeeded( + transferStatus = AssetTransferStatus.NOT_DOWNLOADED, + initialAssetDataPath = rememberedAssetDataPath, + downloadIfNeeded = true + ) + } + + val assetDataPath = rememberedAssetDataPath ?: viewModel.localAssetPath + + if (assetDataPath != null) { + SubcomposeAsyncImage( + model = assetDataPath.toPath(normalize = true).toFile(), + contentDescription = stringResource(R.string.content_description_image_message), + modifier = modifier, alignment = Alignment.Center, contentScale = ContentScale.Crop ) + } else { + Box( + modifier = modifier.background(MaterialTheme.wireColorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_gallery), + contentDescription = stringResource(R.string.content_description_image_message), + tint = MaterialTheme.wireColorScheme.secondaryText + ) + } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/AssetLocalPathViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/AssetLocalPathViewModel.kt new file mode 100644 index 00000000000..6cdb81e7c58 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/AssetLocalPathViewModel.kt @@ -0,0 +1,121 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.conversations.messages.item + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.di.ScopedArgs +import com.wire.android.di.ViewModelScopedPreview +import com.wire.android.di.scopedArgs +import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.logic.data.asset.AssetTransferStatus +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.feature.asset.GetMessageAssetUseCase +import com.wire.kalium.logic.feature.asset.MessageAssetResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +@Serializable +data class AssetLocalPathArgs( + val conversationId: ConversationId, + val messageId: String, +) : ScopedArgs { + override val key: String = "AssetLocalPathArgs:$conversationId:$messageId" +} + +@ViewModelScopedPreview +interface AssetLocalPathViewModel { + val localAssetPath: String? get() = null + fun resolveIfNeeded( + transferStatus: AssetTransferStatus, + initialAssetDataPath: String?, + downloadIfNeeded: Boolean = false + ) {} +} + +@HiltViewModel +internal class AssetLocalPathViewModelImpl @Inject constructor( + private val getMessageAsset: GetMessageAssetUseCase, + private val dispatchers: DispatcherProvider, + savedStateHandle: SavedStateHandle, +) : ViewModel(), AssetLocalPathViewModel { + private val args: AssetLocalPathArgs = savedStateHandle.scopedArgs() + + override var localAssetPath: String? by mutableStateOf(cachedLocalAssetPaths[args.key]) + private set + + private var resolvingJob: Job? = null + + override fun resolveIfNeeded( + transferStatus: AssetTransferStatus, + initialAssetDataPath: String?, + downloadIfNeeded: Boolean + ) { + if (initialAssetDataPath != null && localAssetPath != initialAssetDataPath) { + cachedLocalAssetPaths[args.key] = initialAssetDataPath + localAssetPath = initialAssetDataPath + } + + val shouldResolve = when { + downloadIfNeeded -> + transferStatus == AssetTransferStatus.NOT_DOWNLOADED || + transferStatus == AssetTransferStatus.SAVED_INTERNALLY + else -> transferStatus == AssetTransferStatus.SAVED_INTERNALLY + } + + if (!shouldResolve || localAssetPath != null || resolvingJob?.isActive == true) { + return + } + + resolvingJob = viewModelScope.launch(dispatchers.io()) { + try { + when (val result = getMessageAsset(args.conversationId, args.messageId).await()) { + is MessageAssetResult.Success -> { + val resolvedPath = result.decodedAssetPath.toString() + withContext(dispatchers.main()) { + cachedLocalAssetPaths[args.key] = resolvedPath + localAssetPath = resolvedPath + } + } + + is MessageAssetResult.Failure -> Unit + } + } finally { + withContext(dispatchers.main()) { + if (resolvingJob === this@launch) { + resolvingJob = null + } + } + } + } + } + + private companion object { + val cachedLocalAssetPaths = ConcurrentHashMap() + } +} 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 d82c0ae8061..a7667c85996 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 @@ -6,11 +6,17 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.wire.android.R +import com.wire.android.di.hiltViewModelScoped import com.wire.android.media.audiomessage.AudioMessageArgs import com.wire.android.model.Clickable import com.wire.android.ui.common.applyIf @@ -40,6 +46,7 @@ import com.wire.android.ui.home.conversations.model.messagetypes.video.VideoMess import com.wire.android.ui.theme.Accent import com.wire.android.util.launchGeoIntent import com.wire.kalium.logic.data.asset.AssetTransferStatus +import okio.Path.Companion.toPath @Composable internal fun UIMessage.Regular.MessageContentAndStatus( @@ -143,21 +150,67 @@ private fun MessageContent( ) { when (messageContent) { is UIMessageContent.ImageMessage -> { + val viewModel: AssetLocalPathViewModel = + hiltViewModelScoped( + AssetLocalPathArgs(message.conversationId, message.header.messageId) + ) + var rememberedAssetDataPath by rememberSaveable(message.header.messageId) { + mutableStateOf(null) + } + + LaunchedEffect(viewModel.localAssetPath) { + if (viewModel.localAssetPath != null) { + rememberedAssetDataPath = viewModel.localAssetPath + } + } + + LaunchedEffect(assetStatus, rememberedAssetDataPath) { + viewModel.resolveIfNeeded( + transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, + initialAssetDataPath = rememberedAssetDataPath, + downloadIfNeeded = true + ) + } + MessageImage( - asset = messageContent.asset, + asset = null, imgParams = messageContent.params, transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, onImageClick = onImageClick, - messageStyle = messageStyle + messageStyle = messageStyle, + assetPath = (rememberedAssetDataPath ?: viewModel.localAssetPath)?.toPath(normalize = true) ) } is UIMessageContent.VideoMessage -> { + val viewModel: AssetLocalPathViewModel = + hiltViewModelScoped( + AssetLocalPathArgs(message.conversationId, message.header.messageId) + ) + var rememberedAssetDataPath by rememberSaveable(message.header.messageId) { + mutableStateOf(null) + } + + LaunchedEffect(viewModel.localAssetPath) { + if (viewModel.localAssetPath != null) { + rememberedAssetDataPath = viewModel.localAssetPath + } + } + + LaunchedEffect(assetStatus, rememberedAssetDataPath) { + viewModel.resolveIfNeeded( + transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, + initialAssetDataPath = rememberedAssetDataPath, + downloadIfNeeded = false + ) + } + + val assetDataPath = rememberedAssetDataPath ?: viewModel.localAssetPath VideoMessage( assetSize = messageContent.assetSizeInBytes, assetName = messageContent.assetName, assetExtension = messageContent.assetExtension, - assetDataPath = messageContent.assetDataPath, + assetDataPath = assetDataPath, params = messageContent.params, duration = messageContent.duration, transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, @@ -251,11 +304,34 @@ private fun MessageContent( } is UIMessageContent.AssetMessage -> { + val viewModel: AssetLocalPathViewModel = + hiltViewModelScoped( + AssetLocalPathArgs(message.conversationId, message.header.messageId) + ) + var rememberedAssetDataPath by rememberSaveable(message.header.messageId) { + mutableStateOf(null) + } + + LaunchedEffect(viewModel.localAssetPath) { + if (viewModel.localAssetPath != null) { + rememberedAssetDataPath = viewModel.localAssetPath + } + } + + LaunchedEffect(assetStatus, rememberedAssetDataPath) { + viewModel.resolveIfNeeded( + transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, + initialAssetDataPath = rememberedAssetDataPath, + downloadIfNeeded = false + ) + } + + val assetDataPath = rememberedAssetDataPath ?: viewModel.localAssetPath MessageAsset( assetName = messageContent.assetName, assetExtension = messageContent.assetExtension, assetSizeInBytes = messageContent.assetSizeInBytes, - assetDataPath = messageContent.assetDataPath, + assetDataPath = assetDataPath, assetTransferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, onAssetClick = onAssetClick, messageStyle = messageStyle diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt index 1af642d0aef..ded310ff63e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt @@ -73,16 +73,21 @@ internal fun SwipeableMessageBox( modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { + if (configuration is SwipeableMessageConfiguration.NotSwipeable) { + content() + return + } + SwipeableBox( messageStyle = messageStyle, modifier = modifier, - onSwipeRight = (configuration as? SwipeableMessageConfiguration.Swipeable)?.onSwipedRight?.let { + onSwipeRight = (configuration as SwipeableMessageConfiguration.Swipeable).onSwipedRight?.let { SwipeAction( icon = R.drawable.ic_reply, action = it, ) }, - onSwipeLeft = (configuration as? SwipeableMessageConfiguration.Swipeable)?.onSwipedLeft?.let { + onSwipeLeft = configuration.onSwipedLeft?.let { SwipeAction( icon = R.drawable.ic_react, action = it, 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 89cc58eb389..69ae8b8af91 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 @@ -344,7 +344,6 @@ fun mockAssetMessage(assetId: String = "asset1", messageId: String = "msg1") = U assetExtension = "ZIP", assetId = UserAssetId(assetId, "domain"), assetSizeInBytes = 21957335, - assetDataPath = null, ), messageFooter = mockEmptyFooter, source = MessageSource.Self @@ -408,7 +407,6 @@ fun mockedVideo(width: Int = 800, height: Int = 600, assetName: String = "video. assetSizeInBytes = 123456, assetName = assetName, assetExtension = "mp4", - assetDataPath = null, params = VisualMediaParams(width, height), duration = 12412412, ) 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 64ce3f1a9f5..b5522708a9f 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 @@ -24,6 +24,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize @@ -83,7 +84,6 @@ import com.wire.kalium.logic.data.asset.AssetTransferStatus.FAILED_DOWNLOAD import com.wire.kalium.logic.data.asset.AssetTransferStatus.FAILED_UPLOAD import com.wire.kalium.logic.data.asset.AssetTransferStatus.NOT_FOUND import com.wire.kalium.logic.data.asset.AssetTransferStatus.UPLOAD_IN_PROGRESS -import com.wire.kalium.logic.data.asset.isSaved import kotlinx.collections.immutable.PersistentList import okio.Path @@ -218,8 +218,10 @@ fun MessageImage( messageStyle: MessageStyle, transferStatus: AssetTransferStatus, onImageClick: Clickable, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + assetPath: Path? = null, ) { + val imageSize = imgParams.normalizedSize().size() Box( modifier .applyIf(!messageStyle.isBubble()) { @@ -243,61 +245,84 @@ fun MessageImage( ), contentAlignment = Alignment.Center ) { - val alignCenterModifier = Modifier.align(Alignment.Center) - asset?.let { - DisplayableImageMessage( - imageData = it, - size = imgParams.normalizedSize().size(), - messageStyle = messageStyle, - modifier = alignCenterModifier - ) - } + MessageImageContent(asset = asset, assetPath = assetPath, size = imageSize, messageStyle = messageStyle) + MessageImageOverlay( + hasImageSource = asset != null || assetPath != null, + size = imageSize, + transferStatus = transferStatus, + messageStyle = messageStyle + ) + } +} - val shouldAddScrimBg = asset != null && transferStatus.isSaved() - Box( - Modifier - .applyIf(shouldAddScrimBg) { - background(colorsScheme().scrim) - }, - contentAlignment = Alignment.Center - ) { - when (transferStatus) { - UPLOAD_IN_PROGRESS, DOWNLOAD_IN_PROGRESS -> { - ImageMessageInProgress( - size = imgParams.normalizedSize().size(), - isDownloading = transferStatus == DOWNLOAD_IN_PROGRESS, - color = colorsScheme().onScrim, - ) - } +@Composable +private fun BoxScope.MessageImageContent( + asset: ImageAsset.Remote?, + assetPath: Path?, + size: DpSize, + messageStyle: MessageStyle, +) { + val alignCenterModifier = Modifier.align(Alignment.Center) + when { + assetPath != null -> AsyncImageMessage( + assetPath = assetPath, + size = size, + messageStyle = messageStyle, + modifier = alignCenterModifier + ) - NOT_FOUND -> { - ImageMessageFailed( - size = imgParams.normalizedSize().size(), - isDownloadFailure = true, - errorColor = if (shouldAddScrimBg) { - colorsScheme().onScrim - } else { - messageStyle.error() - } - ) - } + asset != null -> DisplayableImageMessage( + imageData = asset, + size = size, + messageStyle = messageStyle, + modifier = alignCenterModifier + ) + } +} - // Show error placeholder - FAILED_UPLOAD, FAILED_DOWNLOAD -> { - ImageMessageFailed( - size = imgParams.normalizedSize().size(), - isDownloadFailure = transferStatus == FAILED_DOWNLOAD, - errorColor = if (shouldAddScrimBg) { - colorsScheme().onScrim - } else { - messageStyle.error() - } - ) - } +@Composable +private fun MessageImageOverlay( + hasImageSource: Boolean, + size: DpSize, + transferStatus: AssetTransferStatus, + messageStyle: MessageStyle, +) { + when (transferStatus) { + UPLOAD_IN_PROGRESS, DOWNLOAD_IN_PROGRESS -> { + ImageMessageInProgress( + size = size, + isDownloading = transferStatus == DOWNLOAD_IN_PROGRESS, + color = colorsScheme().onScrim, + ) + } - else -> {} + AssetTransferStatus.NOT_DOWNLOADED -> { + if (!hasImageSource) { + ImageMessageInProgress( + size = size, + isDownloading = true, + color = messageStyle.textColor(), + ) } } + + NOT_FOUND -> { + ImageMessageFailed( + size = size, + isDownloadFailure = true, + errorColor = messageStyle.error() + ) + } + + FAILED_UPLOAD, FAILED_DOWNLOAD -> { + ImageMessageFailed( + size = size, + isDownloadFailure = transferStatus == FAILED_DOWNLOAD, + errorColor = messageStyle.error() + ) + } + + else -> Unit } } @@ -348,7 +373,7 @@ fun MediaAssetImage( } assetPath != null -> { - AsyncImageMessage(assetPath, size) + AsyncImageMessage(assetPath, size, messageStyle) } asset != null -> { 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 eeb3c390dc3..03628259c33 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 @@ -382,7 +382,6 @@ sealed interface UIMessageContent { val assetExtension: String, val assetId: AssetId, val assetSizeInBytes: Long, - val assetDataPath: String?, override val deliveryStatus: DeliveryStatusContent = DeliveryStatusContent.CompleteDelivery ) : Regular, PartialDeliverable @@ -400,7 +399,6 @@ sealed interface UIMessageContent { val assetExtension: String, val assetId: AssetId, val assetSizeInBytes: Long, - val assetDataPath: String?, val params: VisualMediaParams, val duration: Long?, override val deliveryStatus: DeliveryStatusContent = DeliveryStatusContent.CompleteDelivery 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 26ec53e41eb..04a6f6ab78f 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 @@ -87,12 +87,20 @@ fun DisplayableImageMessage( fun AsyncImageMessage( assetPath: Path, size: DpSize, + messageStyle: MessageStyle, modifier: Modifier = Modifier ) { SubcomposeAsyncImage( assetPath.toFile(), contentDescription = stringResource(R.string.content_description_image_message), - modifier = modifier.requiredSize(size), + modifier = modifier + .applyIf(messageStyle.isBubble()) { + fillMaxWidth() + .height(size.height) + } + .applyIf(!messageStyle.isBubble()) { + requiredSize(size) + }, loading = { _ -> Box( modifier = Modifier.size(MaterialTheme.wireDimensions.spacing24x), diff --git a/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt b/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt index 96b846606be..cac03c6a25a 100644 --- a/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt +++ b/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt @@ -74,16 +74,12 @@ object TestMessage { assetDomain = "some-asset-domain.com", encryptionAlgorithm = MessageEncryptionAlgorithm.AES_GCM ) - val DUMMY_ASSET_LOCAL_DATA = AssetContent.LocalData( - assetDataPath = "local_asset_path" - ) val ASSET_IMAGE_CONTENT = AssetContent( 0L, "name", "image/jpg", AssetContent.AssetMetadata.Image(100, 100), DUMMY_ASSET_REMOTE_DATA, - DUMMY_ASSET_LOCAL_DATA, ) val GENERIC_ASSET_CONTENT = AssetContent( 0L, @@ -91,7 +87,6 @@ object TestMessage { "application/zip", null, DUMMY_ASSET_REMOTE_DATA, - DUMMY_ASSET_LOCAL_DATA, ) val ASSET_MESSAGE = Message.Regular( id = "messageID", diff --git a/kalium b/kalium index 02fff48e637..f30c54ab5b1 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 02fff48e637a8d7db587a8a84ce140849dfe04a1 +Subproject commit f30c54ab5b1b985739ecbde76eeb11c7a4c7ca74