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 747033e0848..a0e68249ea0 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -1456,7 +1456,7 @@ public final class io/getstream/chat/android/compose/ui/components/messages/Mess } public final class io/getstream/chat/android/compose/ui/components/messages/MessageContentKt { - public static final fun MessageContent (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;III)V + public static final fun MessageContent (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;III)V } public final class io/getstream/chat/android/compose/ui/components/messages/MessageFooterKt { @@ -5370,20 +5370,22 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageReactionsPi public final class io/getstream/chat/android/compose/ui/theme/MessageRegularContentParams { public static final field $stable I - public fun (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V - public synthetic fun (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V + public synthetic fun (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/Message; public final fun component2 ()Lio/getstream/chat/android/models/User; - public final fun component3 ()Lkotlin/jvm/functions/Function1; + public final fun component3 ()Lio/getstream/chat/android/compose/state/messages/MessageAlignment; public final fun component4 ()Lkotlin/jvm/functions/Function1; public final fun component5 ()Lkotlin/jvm/functions/Function1; public final fun component6 ()Lkotlin/jvm/functions/Function1; - public final fun component7 ()Lkotlin/jvm/functions/Function2; - public final fun copy (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams; - public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams;Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams; + public final fun component7 ()Lkotlin/jvm/functions/Function1; + public final fun component8 ()Lkotlin/jvm/functions/Function2; + public final fun copy (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams;Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/compose/state/messages/MessageAlignment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageRegularContentParams; public fun equals (Ljava/lang/Object;)Z public final fun getCurrentUser ()Lio/getstream/chat/android/models/User; public final fun getMessage ()Lio/getstream/chat/android/models/Message; + public final fun getMessageAlignment ()Lio/getstream/chat/android/compose/state/messages/MessageAlignment; public final fun getOnLinkClick ()Lkotlin/jvm/functions/Function2; public final fun getOnLongItemClick ()Lkotlin/jvm/functions/Function1; public final fun getOnMediaGalleryPreviewResult ()Lkotlin/jvm/functions/Function1; 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 8c0ffb8e724..815f763b3a7 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 @@ -64,6 +64,7 @@ import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import coil3.ColorImage import coil3.annotation.ExperimentalCoilApi @@ -217,7 +218,6 @@ internal fun SingleMediaAttachment( onContentItemClick: (MediaAttachmentClickData) -> Unit, overlayContent: @Composable (attachmentType: String?) -> Unit, ) { - val isVideo = attachment.isVideo() // Depending on the CDN, images might not contain their original dimensions val ratio: Float by remember(key1 = attachment.originalWidth, key2 = attachment.originalHeight) { derivedStateOf { @@ -237,10 +237,7 @@ internal fun SingleMediaAttachment( attachment = attachment, modifier = modifier .applyIf(!shouldBeFullSize) { padding(MessageStyling.messageSectionPadding) } - .size( - width = 250.dp, - height = singleMediaAttachmentHeight(isVideo, ratio), - ), + .size(singleMediaAttachmentSize(ratio)), shape = if (shouldBeFullSize) null else RoundedCornerShape(StreamTokens.radiusLg), message = message, onMediaGalleryPreviewResult = onMediaGalleryPreviewResult, @@ -639,20 +636,35 @@ public data class MediaAttachmentClickData internal constructor( ) /** - * Calculates the actual single-media attachment height, based on the configurable width, maxHeight and the aspectRatio. + * Calculates the single-media attachment size using orientation-based bounding boxes. + * + * - Landscape (ratio > 1): max 256×192 dp, fills width first. + * - Portrait (ratio < 1): max 192×256 dp, fills height first. + * - Square (ratio ≈ 1): 256×256 dp. * - * @param isVideo true if "video", false if "image". - * @param aspectRatio the desired aspect ratio. + * Media maintains its aspect ratio and scales to fit the bounding box. + * + * @param aspectRatio the width-to-height ratio of the media. */ -@Composable -private fun singleMediaAttachmentHeight(isVideo: Boolean, aspectRatio: Float): Dp { - val maxHeight = if (isVideo) { - 400.dp - } else { - 600.dp +private fun singleMediaAttachmentSize(aspectRatio: Float): DpSize = when { + aspectRatio > 1f -> { + // Landscape: bounding box 256×192 + val width = 256.dp + val height = (width / aspectRatio).coerceAtMost(192.dp) + DpSize(width, height) + } + + aspectRatio < 1f -> { + // Portrait: bounding box 192×256 + val height = 256.dp + val width = (height * aspectRatio).coerceAtMost(192.dp) + DpSize(width, height) + } + + else -> { + // Square + DpSize(256.dp, 256.dp) } - val heightAccordingAspectRatio = 250.dp / aspectRatio - return minOf(heightAccordingAspectRatio, maxHeight) } /** diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt index d51316ed2e9..9145ac78c62 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageContent.kt @@ -45,6 +45,7 @@ import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.client.utils.message.isGiphyEphemeral import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.mediagallerypreview.MediaGalleryPreviewResult +import io.getstream.chat.android.compose.state.messages.MessageAlignment import io.getstream.chat.android.compose.state.messages.attachments.AttachmentState import io.getstream.chat.android.compose.ui.theme.AudioRecordAttachmentContentParams import io.getstream.chat.android.compose.ui.theme.ChatTheme @@ -91,6 +92,7 @@ public fun MessageContent( onGiphyActionClick: (GiphyAction) -> Unit = {}, onQuotedMessageClick: (Message) -> Unit = {}, onUserMentionClick: (User) -> Unit = {}, + messageAlignment: MessageAlignment = MessageAlignment.Start, onLinkClick: ((Message, String) -> Unit)? = null, onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit = {}, giphyEphemeralContent: @Composable () -> Unit = { @@ -116,6 +118,7 @@ public fun MessageContent( params = MessageRegularContentParams( message = message, currentUser = currentUser, + messageAlignment = messageAlignment, onLongItemClick = onLongItemClick, onMediaGalleryPreviewResult = onMediaGalleryPreviewResult, onQuotedMessageClick = onQuotedMessageClick, @@ -178,6 +181,7 @@ internal fun DefaultMessageDeletedContent( internal fun DefaultMessageContent( message: Message, currentUser: User?, + messageAlignment: MessageAlignment = MessageAlignment.Start, onLongItemClick: (Message) -> Unit, onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit = {}, onQuotedMessageClick: (Message) -> Unit, @@ -186,7 +190,7 @@ internal fun DefaultMessageContent( ) { val componentFactory = ChatTheme.componentFactory - Column { + Column(horizontalAlignment = messageAlignment.contentAlignment) { val quotedMessage = message.replyTo if (quotedMessage != null) { componentFactory.MessageQuotedContent( diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt index 5f923dde30a..3beef3ea758 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt @@ -761,6 +761,7 @@ public fun RegularMessageContent( MessageContent( message = message, currentUser = messageItem.currentUser, + messageAlignment = messageAlignment, onLongItemClick = onLongItemClick, onGiphyActionClick = onGiphyActionClick, onMediaGalleryPreviewResult = onMediaGalleryPreviewResult, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index 2f6883e063c..9bb28add72b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -1077,6 +1077,7 @@ public interface ChatComponentFactory { DefaultMessageContent( message = params.message, currentUser = params.currentUser, + messageAlignment = params.messageAlignment, onLongItemClick = params.onLongItemClick, onMediaGalleryPreviewResult = params.onMediaGalleryPreviewResult, onQuotedMessageClick = params.onQuotedMessageClick, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index 3b3f67b3e38..e938ad4ed91 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.state.channels.list.ItemState import io.getstream.chat.android.compose.state.mediagallerypreview.MediaGalleryPreviewResult import io.getstream.chat.android.compose.state.messageoptions.MessageOptionItemState +import io.getstream.chat.android.compose.state.messages.MessageAlignment import io.getstream.chat.android.compose.state.messages.MessageReactionItemState import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerItemState import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerMode @@ -787,6 +788,7 @@ public data class MessageDeletedContentParams( * * @param message The message to display. * @param currentUser The currently logged in user. + * @param messageAlignment The horizontal alignment of the message in the message list. * @param onLongItemClick Action invoked when a message is long-clicked. * @param onMediaGalleryPreviewResult Action invoked with the media gallery preview result. * @param onQuotedMessageClick Action invoked when a quoted message is clicked. @@ -796,6 +798,7 @@ public data class MessageDeletedContentParams( public data class MessageRegularContentParams( val message: Message, val currentUser: User?, + val messageAlignment: MessageAlignment, val onLongItemClick: (Message) -> Unit, val onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit, val onQuotedMessageClick: (Message) -> Unit, diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_single_media_attachment_content.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_single_media_attachment_content.png index fc29544a3b0..3422ba33310 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_single_media_attachment_content.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_single_media_attachment_content.png differ diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper.kt index a7aac6652dd..afbfba5b66b 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper.kt @@ -17,13 +17,18 @@ package io.getstream.chat.android.ui.common.helper.internal import android.content.Context +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever import android.net.Uri import androidx.annotation.WorkerThread +import io.getstream.chat.android.client.utils.attachment.isImage +import io.getstream.chat.android.client.utils.attachment.isVideo import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData import io.getstream.log.taggedLogger +import java.io.File /** * Handles querying device storage for attachment metadata and converting between @@ -111,9 +116,17 @@ public class AttachmentStorageHelper( logger.w { "[resolveAttachmentFiles] Failed to resolve file for URI: $sourceUri" } return@mapNotNull null } + + val (width, height) = if (attachment.originalWidth == null && attachment.originalHeight == null) { + resolveLocalDimensions(file, attachment) + } else { + attachment.originalWidth to attachment.originalHeight + } attachment.copy( upload = file, extraData = attachment.extraData - EXTRA_SOURCE_URI, + originalWidth = width, + originalHeight = height, ) } @@ -127,6 +140,37 @@ public class AttachmentStorageHelper( public fun resolveMetadata(uris: List): List = storageHelper.getAttachmentsFromUriList(context, uris).let(attachmentFilter::filterAttachments) + @Suppress("MagicNumber") + private fun resolveLocalDimensions(file: File, attachment: Attachment): Pair = when { + attachment.isImage() -> { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, options) + val w = options.outWidth.takeIf { it > 0 } + val h = options.outHeight.takeIf { it > 0 } + w to h + } + + attachment.isVideo() -> { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(file.absolutePath) + val w = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) + ?.toIntOrNull()?.takeIf { it > 0 } + val h = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) + ?.toIntOrNull()?.takeIf { it > 0 } + val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) + ?.toIntOrNull() ?: 0 + if (rotation == 90 || rotation == 270) h to w else w to h + } catch (_: Exception) { + null to null + } finally { + retriever.release() + } + } + + else -> null to null + } + public companion object { /** * Key in [Attachment.extraData] holding the original content URI string