diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d7c814bbf..eb68cc66b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ ## stream-chat-android-compose ### 🐞 Fixed - Crash when recording audio on a message reply. [#5642](https://github.com/GetStream/stream-chat-android/pull/5642) +- Fix fading issue in media attachment content items. [#5631](https://github.com/GetStream/stream-chat-android/pull/5631) ### ⬆️ Improved - Autofocus the input fields in the poll creation screen. [#5629](https://github.com/GetStream/stream-chat-android/pull/5629) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 095c6e7af37..c5f7dd3f1b5 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -408,22 +408,11 @@ public final class io/getstream/chat/android/compose/ui/attachments/content/Comp public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; } -public final class io/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$GiphyAttachmentContentKt { - public static final field INSTANCE Lio/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$GiphyAttachmentContentKt; - public static field lambda-1 Lkotlin/jvm/functions/Function3; - public fun ()V - public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; -} - public final class io/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$MediaAttachmentContentKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$MediaAttachmentContentKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; - public static field lambda-2 Lkotlin/jvm/functions/Function3; - public static field lambda-3 Lkotlin/jvm/functions/Function4; public fun ()V public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function4; } public final class io/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$MediaAttachmentPreviewContentKt { @@ -577,18 +566,10 @@ public final class io/getstream/chat/android/compose/ui/attachments/factory/Uplo public final class io/getstream/chat/android/compose/ui/attachments/preview/ComposableSingletons$MediaGalleryPreviewActivityKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/attachments/preview/ComposableSingletons$MediaGalleryPreviewActivityKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; - public static field lambda-2 Lkotlin/jvm/functions/Function3; - public static field lambda-3 Lkotlin/jvm/functions/Function4; - public static field lambda-4 Lkotlin/jvm/functions/Function2; - public static field lambda-5 Lkotlin/jvm/functions/Function3; - public static field lambda-6 Lkotlin/jvm/functions/Function4; + public static field lambda-2 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function4; - public final fun getLambda-4$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-5$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-6$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/attachments/preview/ComposableSingletons$MediaPreviewActivityKt { diff --git a/stream-chat-android-compose/build.gradle.kts b/stream-chat-android-compose/build.gradle.kts index 70017ffcabe..de373cb9c0d 100644 --- a/stream-chat-android-compose/build.gradle.kts +++ b/stream-chat-android-compose/build.gradle.kts @@ -97,13 +97,13 @@ dependencies { // Coil implementation(libs.coil.compose) implementation(libs.skydoves.landscapist.coil) - implementation(libs.skydoves.landscapist.placeholder) implementation(libs.skydoves.landscapist.animation) implementation(libs.coil.gif) implementation(libs.coil.video) // UI implementation(libs.reorderable) + implementation(libs.shimmer.compose) // Tests testImplementation(project(":stream-chat-android-test")) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/GiphyAttachmentContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/GiphyAttachmentContent.kt index b75cf92f1ce..afad9b11a44 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/GiphyAttachmentContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/GiphyAttachmentContent.kt @@ -43,16 +43,14 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import com.skydoves.landscapist.ImageOptions -import com.skydoves.landscapist.components.rememberImageComponent -import com.skydoves.landscapist.placeholder.shimmer.Shimmer -import com.skydoves.landscapist.placeholder.shimmer.ShimmerPlugin +import coil.compose.AsyncImagePainter import io.getstream.chat.android.client.utils.attachment.isGiphy import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.messages.attachments.AttachmentState +import io.getstream.chat.android.compose.ui.components.ShimmerProgressIndicator import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.StreamDimens -import io.getstream.chat.android.compose.ui.util.StreamImage +import io.getstream.chat.android.compose.ui.util.StreamAsyncImage import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.common.utils.GiphyInfoType import io.getstream.chat.android.ui.common.utils.GiphySizingMode @@ -159,19 +157,22 @@ public fun GiphyAttachmentContent( onLongClick = { onLongItemClick(message) }, ), ) { - StreamImage( - modifier = Modifier.fillMaxSize(), - data = { giphyInfo?.url }, - component = rememberImageComponent { - +ShimmerPlugin( - Shimmer.Resonate( - baseColor = ChatTheme.colors.mediaShimmerBase, - highlightColor = ChatTheme.colors.mediaShimmerHighlights, - ), + StreamAsyncImage( + data = giphyInfo?.url, + ) { state -> + if (state !is AsyncImagePainter.State.Success) { + ShimmerProgressIndicator( + modifier = Modifier.fillMaxSize(), ) - }, - imageOptions = ImageOptions(contentScale = contentScale), - ) + } else { + Image( + modifier = Modifier.fillMaxSize(), + painter = state.painter, + contentDescription = null, + contentScale = contentScale, + ) + } + } Image( modifier = Modifier diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt index 84b25b6f924..a79b4c81a12 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt @@ -16,9 +16,9 @@ package io.getstream.chat.android.compose.ui.attachments.content -import android.annotation.SuppressLint import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.animation.Crossfade import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -44,6 +44,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -57,14 +58,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp +import coil.compose.AsyncImagePainter import coil.request.ImageRequest -import com.skydoves.landscapist.ImageOptions -import com.skydoves.landscapist.animation.crossfade.CrossfadePlugin -import com.skydoves.landscapist.coil.CoilImageState -import com.skydoves.landscapist.coil.rememberCoilImageState -import com.skydoves.landscapist.components.rememberImageComponent -import com.skydoves.landscapist.placeholder.shimmer.Shimmer -import com.skydoves.landscapist.placeholder.shimmer.ShimmerPlugin import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.attachment.isVideo @@ -72,10 +67,11 @@ import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.mediagallerypreview.MediaGalleryPreviewResult import io.getstream.chat.android.compose.state.messages.attachments.AttachmentState import io.getstream.chat.android.compose.ui.attachments.preview.MediaGalleryPreviewContract +import io.getstream.chat.android.compose.ui.components.ShimmerProgressIndicator import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.util.ImageRequestTimeoutHandler import io.getstream.chat.android.compose.ui.util.RetryHash -import io.getstream.chat.android.compose.ui.util.StreamImage -import io.getstream.chat.android.compose.ui.util.onImageNeedsToReload +import io.getstream.chat.android.compose.ui.util.StreamAsyncImage import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.models.Message @@ -385,7 +381,6 @@ internal fun RowScope.MultipleMediaAttachments( * @param overlayContent Represents the content overlaid above attachment previews. * Usually used to display a play button over video previews. */ -@SuppressLint("UnrememberedMutableInteractionSource") @Suppress("LongParameterList", "LongMethod") @OptIn(ExperimentalFoundationApi::class) @Composable @@ -430,26 +425,25 @@ internal fun MediaAttachmentContentItem( } val context = LocalContext.current - val model = remember(retryHash) { + val imageRequest = remember(retryHash) { ImageRequest.Builder(context) .data(data) .setParameter(key = RetryHash, value = retryHash) .build() } - var imageState by rememberCoilImageState() + var imageState by remember { mutableStateOf(AsyncImagePainter.State.Empty) } val mixedMediaPreviewLauncher = rememberLauncherForActivityResult( contract = MediaGalleryPreviewContract(), onResult = { result -> onMediaGalleryPreviewResult(result) }, ) - // Used to refresh the request for the current page - // if it has previously failed. - onImageNeedsToReload( + // Used to refresh the request for the current page if it has previously failed. + ImageRequestTimeoutHandler( data = data, connectionState = connectionState, - coilImageState = imageState, + imageState = imageState, ) { retryHash++ } @@ -468,7 +462,7 @@ internal fun MediaAttachmentContentItem( .fillMaxWidth() .testTag("Stream_MediaContent_$testTag") .combinedClickable( - interactionSource = MutableInteractionSource(), + interactionSource = remember { MutableInteractionSource() }, indication = ripple(), onClick = { if (message.syncStatus == SyncStatus.COMPLETED) { @@ -497,36 +491,51 @@ internal fun MediaAttachmentContentItem( ChatTheme.colors.videoBackgroundMessageList } - StreamImage( + StreamAsyncImage( + imageRequest = imageRequest, modifier = modifier .fillMaxSize() .background(backgroundColor), - imageRequest = { model }, - onImageStateChanged = { imageState = it }, - component = rememberImageComponent { - +ShimmerPlugin( - Shimmer.Resonate( - baseColor = ChatTheme.colors.mediaShimmerBase, - highlightColor = ChatTheme.colors.mediaShimmerHighlights, - ), - ) - +CrossfadePlugin() - }, - failure = { - Icon( - tint = ChatTheme.colors.disabled, - modifier = Modifier.fillMaxSize(0.4f), - painter = painterResource( - id = R.drawable.stream_compose_ic_image_picker, - ), - contentDescription = null, - ) - }, - imageOptions = ImageOptions(contentScale = ContentScale.Crop), - ) + contentScale = ContentScale.Crop, + ) { asyncImageState -> + imageState = asyncImageState + + Crossfade(targetState = asyncImageState) { state -> + when (state) { + is AsyncImagePainter.State.Empty, + is AsyncImagePainter.State.Loading, + -> ShimmerProgressIndicator( + modifier = Modifier.fillMaxSize(), + ) - if (imageState !is CoilImageState.Loading) { - overlayContent(attachment.type) + is AsyncImagePainter.State.Success -> { + Image( + modifier = Modifier.fillMaxSize(), + painter = state.painter, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + overlayContent(attachment.type) + } + + is AsyncImagePainter.State.Error -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + tint = ChatTheme.colors.disabled, + modifier = Modifier.fillMaxSize(0.4f), + painter = painterResource(R.drawable.stream_compose_ic_image_picker), + contentDescription = stringResource( + id = R.string.stream_ui_message_list_attachment_load_failed, + ), + ) + overlayContent(attachment.type) + } + } + } + } } } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivity.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivity.kt index 6218eca55c4..aa098c19acb 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivity.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivity.kt @@ -35,11 +35,13 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -114,18 +116,13 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope +import coil.compose.AsyncImagePainter import coil.request.ImageRequest import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.PagerState import com.google.accompanist.pager.rememberPagerState import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState -import com.skydoves.landscapist.ImageOptions -import com.skydoves.landscapist.coil.CoilImageState -import com.skydoves.landscapist.coil.rememberCoilImageState -import com.skydoves.landscapist.components.rememberImageComponent -import com.skydoves.landscapist.placeholder.shimmer.Shimmer -import com.skydoves.landscapist.placeholder.shimmer.ShimmerPlugin import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.attachment.isVideo @@ -145,13 +142,16 @@ import io.getstream.chat.android.compose.state.mediagallerypreview.toMessage import io.getstream.chat.android.compose.ui.attachments.content.PlayButton import io.getstream.chat.android.compose.ui.components.LoadingIndicator import io.getstream.chat.android.compose.ui.components.NetworkLoadingIndicator +import io.getstream.chat.android.compose.ui.components.ShimmerProgressIndicator import io.getstream.chat.android.compose.ui.components.SimpleDialog import io.getstream.chat.android.compose.ui.components.Timestamp import io.getstream.chat.android.compose.ui.components.avatar.Avatar import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.util.ImageRequestTimeoutHandler import io.getstream.chat.android.compose.ui.util.RetryHash +import io.getstream.chat.android.compose.ui.util.StreamAsyncImage import io.getstream.chat.android.compose.ui.util.StreamImage -import io.getstream.chat.android.compose.ui.util.onImageNeedsToReload +import io.getstream.chat.android.compose.ui.util.isCompleted import io.getstream.chat.android.compose.util.attachmentDownloadState import io.getstream.chat.android.compose.util.onDownloadHandleRequest import io.getstream.chat.android.compose.viewmodel.mediapreview.MediaGalleryPreviewViewModel @@ -810,12 +810,11 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { val imageRequest = remember(retryHash) { ImageRequest.Builder(context) .data(data) - .crossfade(true) .setParameter(key = RetryHash, value = retryHash) .build() } - var imageState by rememberCoilImageState() + var imageState by remember { mutableStateOf(AsyncImagePainter.State.Empty) } val density = LocalDensity.current val parentSize = Size(density.run { maxWidth.toPx() }, density.run { maxHeight.toPx() }) @@ -826,21 +825,20 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { val scale by animateFloatAsState(targetValue = currentScale, label = "") - // Used to refresh the request for the current page - // if it has previously failed. - onImageNeedsToReload( + // Used to refresh the request if it has previously failed. + ImageRequestTimeoutHandler( data = data, connectionState = mediaGalleryPreviewViewModel.connectionState, - coilImageState = imageState, + imageState = imageState, ) { retryHash++ } - val transformModifier = if (imageState is CoilImageState.Success) { - val state = imageState as CoilImageState.Success + val transformModifier = if (imageState is AsyncImagePainter.State.Success) { + val state = imageState as AsyncImagePainter.State.Success val size = Size( - state.drawable?.intrinsicWidth?.toFloat() ?: 0f, - state.drawable?.intrinsicHeight?.toFloat() ?: 0f, + width = state.result.drawable.intrinsicWidth.toFloat(), + height = state.result.drawable.intrinsicHeight.toFloat(), ) Modifier .aspectRatio(size.width / size.height, true) @@ -853,7 +851,8 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center, ) { - StreamImage( + StreamAsyncImage( + imageRequest = imageRequest, modifier = transformModifier .graphicsLayer( scaleY = scale, @@ -924,26 +923,29 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { } } }, - imageRequest = { imageRequest }, - component = rememberImageComponent { - +ShimmerPlugin( - Shimmer.Resonate( - baseColor = ChatTheme.colors.mediaShimmerBase, - highlightColor = ChatTheme.colors.mediaShimmerHighlights, - ), - ) - }, - failure = { - Icon( - tint = ChatTheme.colors.textLowEmphasis, - modifier = Modifier.fillMaxSize(0.4f), - painter = painterResource( - id = R.drawable.stream_compose_ic_image_picker, - ), - contentDescription = null, - ) - }, - ) + ) { asyncImageState -> + imageState = asyncImageState + + Crossfade(targetState = asyncImageState) { state -> + when (state) { + is AsyncImagePainter.State.Empty, + is AsyncImagePainter.State.Loading, + -> ShimmerProgressIndicator( + modifier = Modifier.fillMaxSize(), + ) + + is AsyncImagePainter.State.Success, + -> Image( + modifier = Modifier.fillMaxSize(), + painter = state.painter, + contentDescription = null, + ) + + is AsyncImagePainter.State.Error, + -> ErrorIcon(modifier = Modifier.fillMaxSize()) + } + } + } Log.d("isCurrentPage", "${page != pagerState.currentPage}") @@ -1580,9 +1582,7 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { // Used as a workaround for Coil's lack of a retry policy. // See: https://github.com/coil-kt/coil/issues/884#issuecomment-975932886 - var retryHash by remember { - mutableStateOf(0) - } + var retryHash by remember { mutableIntStateOf(0) } val coroutineScope = rememberCoroutineScope() @@ -1590,12 +1590,16 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { modifier = Modifier .fillMaxWidth() .aspectRatio(1f) - .clickable { - coroutineScope.launch { - mediaGalleryPreviewViewModel.toggleGallery(isShowingGallery = false) - pagerState.animateScrollToPage(index) - } - }, + .clickable( + indication = ripple(), + interactionSource = remember { MutableInteractionSource() }, + onClick = { + coroutineScope.launch { + mediaGalleryPreviewViewModel.toggleGallery(isShowingGallery = false) + pagerState.animateScrollToPage(index) + } + }, + ), contentAlignment = Alignment.Center, ) { val data = @@ -1613,14 +1617,13 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { .build() } - var imageState by rememberCoilImageState() + var imageState by remember { mutableStateOf(AsyncImagePainter.State.Empty) } - // Used to refresh the request for the current page - // if it has previously failed. - onImageNeedsToReload( + // Used to refresh the request if it has previously failed. + ImageRequestTimeoutHandler( data = data, connectionState = mediaGalleryPreviewViewModel.connectionState, - coilImageState = imageState, + imageState = imageState, ) { retryHash++ } @@ -1631,32 +1634,35 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { ChatTheme.colors.videoBackgroundMediaGalleryPicker } - StreamImage( + StreamAsyncImage( + imageRequest = imageRequest, modifier = Modifier .padding(1.dp) .fillMaxSize() .background(color = backgroundColor), - imageRequest = { imageRequest }, - imageOptions = ImageOptions(contentScale = ContentScale.Crop), - component = rememberImageComponent { - +ShimmerPlugin( - Shimmer.Resonate( - baseColor = ChatTheme.colors.mediaShimmerBase, - highlightColor = ChatTheme.colors.mediaShimmerHighlights, - ), + contentScale = ContentScale.Crop, + ) { asyncImageState -> + imageState = asyncImageState + + when (asyncImageState) { + is AsyncImagePainter.State.Empty, + is AsyncImagePainter.State.Loading, + -> ShimmerProgressIndicator( + modifier = Modifier.fillMaxSize(), ) - }, - failure = { - Icon( - tint = ChatTheme.colors.textLowEmphasis, - modifier = Modifier.fillMaxSize(0.4f), - painter = painterResource( - id = R.drawable.stream_compose_ic_image_picker, - ), + + is AsyncImagePainter.State.Success, + -> Image( + modifier = Modifier.fillMaxSize(), + painter = asyncImageState.painter, contentDescription = null, + contentScale = ContentScale.Crop, ) - }, - ) + + is AsyncImagePainter.State.Error, + -> ErrorIcon(Modifier.fillMaxSize()) + } + } Avatar( modifier = Modifier @@ -1676,7 +1682,7 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { initials = user.initials, ) - if (isVideo && imageState !is CoilImageState.Loading) { + if (isVideo && imageState.isCompleted) { PlayButton( modifier = Modifier .shadow(6.dp, shape = CircleShape) @@ -1688,6 +1694,21 @@ public class MediaGalleryPreviewActivity : AppCompatActivity() { } } + @Composable + private fun ErrorIcon(modifier: Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Icon( + tint = ChatTheme.colors.disabled, + modifier = Modifier.fillMaxSize(0.4f), + painter = painterResource(R.drawable.stream_compose_ic_image_picker), + contentDescription = stringResource(R.string.stream_ui_message_list_attachment_load_failed), + ) + } + } + /** * Fetches individual image resizing options from the bundle and * packs them into [StreamCdnImageResizing]. diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/ShimmerProgressIndicator.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/ShimmerProgressIndicator.kt new file mode 100644 index 00000000000..d2213d53486 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/ShimmerProgressIndicator.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.valentinilk.shimmer.shimmer +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.StreamShimmerTheme + +/** + * Displays a shimmer progress indicator using [StreamShimmerTheme]. + * + * @param modifier The modifier to be applied to the component. + */ +@Composable +internal fun ShimmerProgressIndicator(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .background(color = ChatTheme.colors.mediaShimmerBase) + .shimmer() + .background(color = ChatTheme.colors.mediaShimmerHighlights), + ) +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt index 6c46654106d..c0c57fff11e 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import coil.ImageLoader +import com.valentinilk.shimmer.LocalShimmerTheme import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.header.VersionPrefixHeader import io.getstream.chat.android.compose.ui.attachments.AttachmentFactory @@ -396,6 +397,7 @@ public fun ChatTheme( LocalTypography provides typography, LocalShapes provides shapes, LocalRippleConfiguration provides rippleConfiguration.toRippleConfiguration(), + LocalShimmerTheme provides StreamShimmerTheme, LocalUseDefaultSystemMediaPicker provides useDefaultSystemMediaPicker, LocalUserPresence provides userPresence, LocalComponentFactory provides componentFactory, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamShimmerTheme.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamShimmerTheme.kt new file mode 100644 index 00000000000..36526eabf83 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamShimmerTheme.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.theme + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.infiniteRepeatable +import com.valentinilk.shimmer.ShimmerTheme +import com.valentinilk.shimmer.defaultShimmerTheme +import com.valentinilk.shimmer.shimmerSpec + +/** + * The default [ShimmerTheme] used in the Stream Chat Compose library. + */ +internal val StreamShimmerTheme: ShimmerTheme = defaultShimmerTheme.copy( + animationSpec = infiniteRepeatable( + animation = shimmerSpec( + durationMillis = 1000, + easing = FastOutSlowInEasing, + ), + ), + rotation = 45.0f, +) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/CoilReloadingMechanism.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/CoilReloadingMechanism.kt deleted file mode 100644 index 0dbd19411bd..00000000000 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/CoilReloadingMechanism.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.chat.android.compose.ui.util - -import coil.network.HttpException -import com.skydoves.landscapist.coil.CoilImageState -import io.getstream.chat.android.models.ConnectionState - -/** - * Used to automatically reload the image once all conditions are satisfied, such as - * internet connection regained and the painter being in an error state. - * - * @param data The data containing the image. - * @param connectionState The state of the network connection - * @param coilImageState The state of the async image painter - * @param onReload The lambda function called when the conditions have been met and the image needs to be reloaded. - */ -internal fun onImageNeedsToReload( - data: Any?, - connectionState: ConnectionState, - coilImageState: CoilImageState, - onReload: () -> Unit, -) { - if (data != null && connectionState is ConnectionState.Connected && - coilImageState is CoilImageState.Failure - ) { - val errorCode = (coilImageState.reason as? HttpException)?.response?.code - - if (errorCode == UnsatisfiableRequest) { - onReload() - } - } -} - -/** - * Represents the HTTP code thrown when the COIL image loader has timed out and is unable to fetch the - * image from the web. - */ -private const val UnsatisfiableRequest = 504 diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageRequestTimeoutHandler.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageRequestTimeoutHandler.kt new file mode 100644 index 00000000000..a2ebd6523c8 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageRequestTimeoutHandler.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import coil.compose.AsyncImagePainter +import io.getstream.chat.android.models.ConnectionState +import java.net.SocketTimeoutException + +/** + * Triggers [onTimeout] when the connection is established and the image load has failed due to a timeout. + * + * @param data The data to load. + * @param connectionState The state of the network connection. + * @param imageState The state of the async image painter. + * @param onTimeout The lambda function called when the timeout conditions have been met. + */ +@Composable +internal fun ImageRequestTimeoutHandler( + data: Any?, + connectionState: ConnectionState, + imageState: AsyncImagePainter.State, + onTimeout: () -> Unit, +) { + LaunchedEffect(data, connectionState, imageState) { + if (data != null && + connectionState is ConnectionState.Connected && + imageState is AsyncImagePainter.State.Error + ) { + if (imageState.result.throwable is SocketTimeoutException) { + onTimeout() + } + } + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageUtils.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageUtils.kt index 3ad70e4d974..9b61d19e2cc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageUtils.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageUtils.kt @@ -442,6 +442,12 @@ private fun ImageRequest.size(context: Context, size: Size): ImageRequest = run .build() } +/** + * A completed state is when the image is either successfully loaded or failed to load. + */ +internal val AsyncImagePainter.State.isCompleted: Boolean + get() = this is AsyncImagePainter.State.Success || this is AsyncImagePainter.State.Error + /** * Used to change a parameter set on Coil requests in order * to force Coil into retrying a request.