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 864a3d746c4..5df94f0de37 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 @@ -17,6 +17,7 @@ package io.getstream.chat.android.compose.ui.attachments.content import android.content.Context +import androidx.annotation.VisibleForTesting import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -33,10 +34,14 @@ import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale @@ -120,7 +125,9 @@ public fun GiphyAttachmentContent( val giphyInfo = attachment.giphyInfo(giphyInfoType) - val giphyDimensions: DpSize = calculateSize(giphyInfo, giphySizingMode) + var downloadedRatio by remember(key1 = attachment.giphyFallbackPreviewUrl) { mutableStateOf(null) } + + val giphyDimensions: DpSize = calculateSize(giphyInfo, giphySizingMode, downloadedRatio) val shouldBeFullSize = message.shouldBeDisplayedAsFullSizeAttachment() val giphyTitle = attachment.title?.takeIf(String::isNotBlank) @@ -172,9 +179,19 @@ public fun GiphyAttachmentContent( ) { StreamAsyncImage( data = giphyInfo?.url ?: attachment.giphyFallbackPreviewUrl, - modifier = Modifier.fillMaxSize(), contentDescription = giphyTitle, + modifier = Modifier.fillMaxSize(), contentScale = contentScale, + onState = if (giphyInfo == null) { + { imageState -> + val intrinsicSize = imageState.painter?.intrinsicSize + if (intrinsicSize != null && intrinsicSize.isSpecified && intrinsicSize.height > 0f) { + downloadedRatio = intrinsicSize.width / intrinsicSize.height + } + } + } else { + null + }, ) GiphyLabel( @@ -190,6 +207,7 @@ public fun GiphyAttachmentContent( private fun calculateSize( giphyInfo: GiphyInfo?, giphySizingMode: GiphySizingMode, + downloadedRatio: Float?, ): DpSize { val density = LocalDensity.current @@ -199,7 +217,7 @@ private fun calculateSize( val width = 250.dp val height = 200.dp - val giphyDimensions: DpSize = remember(giphyInfo, density, maxWidth, width, maxHeight, height) { + val giphyDimensions: DpSize = remember(giphyInfo, downloadedRatio, density, maxWidth, width, maxHeight, height) { if (giphyInfo != null) { with(density) { val giphyWidth = giphyInfo.width.toDp() @@ -227,8 +245,16 @@ private fun calculateSize( ) } } + } else if (downloadedRatio == null) { + val side = minOf(maxWidth, maxHeight) + DpSize(side, side) } else { - DpSize(maxWidth, maxHeight) + calculateResultingDimensions( + maxWidth = maxWidth, + maxHeight = maxHeight, + giphyWidth = downloadedRatio.dp, + giphyHeight = 1.dp, + ) } } return giphyDimensions @@ -259,6 +285,8 @@ private fun GiphyLabel(modifier: Modifier) { text = stringResource(R.string.stream_compose_giphy_label), style = ChatTheme.typography.metadataEmphasis, color = colors.badgeTextOnAccent, + maxLines = 1, + softWrap = false, ) } } @@ -278,7 +306,8 @@ private fun GiphyLabel(modifier: Modifier) { * * @return The resulting resized dimensions. */ -private fun calculateResultingDimensions( +@VisibleForTesting +internal fun calculateResultingDimensions( maxWidth: Dp, maxHeight: Dp, giphyWidth: Dp, 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 2b4f3ab5e02..047851c964b 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 @@ -55,6 +55,7 @@ private const val MaxRetries = 3 * @param contentDescription The description to use for the image. * @param modifier Modifier for styling. * @param contentScale The scale to be used for the content. Default is [ContentScale.Fit]. + * @param onState Optional listener invoked when the image loading state changes. */ @Composable internal fun StreamAsyncImage( @@ -62,6 +63,7 @@ internal fun StreamAsyncImage( contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit, + onState: ((AsyncImagePainter.State) -> Unit)? = null, ) { StreamAsyncImage( imageRequest = ImageRequest.Builder(LocalContext.current) @@ -70,6 +72,7 @@ internal fun StreamAsyncImage( modifier = modifier, contentDescription = contentDescription, contentScale = contentScale, + onState = onState, ) } @@ -80,6 +83,7 @@ internal fun StreamAsyncImage( * @param contentDescription The description to use for the image. * @param modifier Modifier for styling. * @param contentScale The scale to be used for the content. Default is [ContentScale.Fit]. + * @param onState Optional listener invoked when the image loading state changes. */ @Composable internal fun StreamAsyncImage( @@ -87,12 +91,16 @@ internal fun StreamAsyncImage( contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit, + onState: ((AsyncImagePainter.State) -> Unit)? = null, ) { StreamAsyncImage( imageRequest = imageRequest, modifier = modifier, contentScale = contentScale, ) { state -> + if (onState != null) { + LaunchedEffect(state) { onState(state) } + } val painter = state.painter if (painter == null) { ShimmerProgressIndicator( diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/content/GiphyAttachmentContentTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/content/GiphyAttachmentContentTest.kt new file mode 100644 index 00000000000..0fb15c51f44 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/content/GiphyAttachmentContentTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2014-2026 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.attachments.content + +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class GiphyAttachmentContentTest { + + @Test + fun `landscape ratio is constrained by the max width`() { + val result = calculateResultingDimensions( + maxWidth = 250.dp, + maxHeight = 200.dp, + giphyWidth = 400.dp, + giphyHeight = 200.dp, + ) + + assertEquals(250f, result.width.value, DELTA) + assertEquals(125f, result.height.value, DELTA) + } + + @Test + fun `portrait ratio is constrained by the max height`() { + val result = calculateResultingDimensions( + maxWidth = 250.dp, + maxHeight = 200.dp, + giphyWidth = 200.dp, + giphyHeight = 400.dp, + ) + + assertEquals(100f, result.width.value, DELTA) + assertEquals(200f, result.height.value, DELTA) + } + + @Test + fun `square ratio is constrained by the smaller max dimension`() { + val result = calculateResultingDimensions( + maxWidth = 250.dp, + maxHeight = 200.dp, + giphyWidth = 300.dp, + giphyHeight = 300.dp, + ) + + assertEquals(200f, result.width.value, DELTA) + assertEquals(200f, result.height.value, DELTA) + } + + private companion object { + private const val DELTA = 0.01f + } +} diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_attachment_content.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_attachment_content.png index b35920eae4c..44b95e84c05 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_attachment_content.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_attachment_content.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_attachment_content_blank_title.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_attachment_content_blank_title.png index b35920eae4c..44b95e84c05 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_attachment_content_blank_title.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_attachment_content_blank_title.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_attachment_content_non-interactive.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_attachment_content_non-interactive.png index b35920eae4c..44b95e84c05 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_attachment_content_non-interactive.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_attachment_content_non-interactive.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_message_content.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_message_content.png index 268d48bf563..4a1ad56953e 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_message_content.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_giphy_message_content.png differ