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 6d6a9f0ea6d..f8c14832adb 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -467,6 +467,8 @@ public final class io/getstream/chat/android/compose/ui/attachments/content/Comp public final fun getLambda$-1718290425$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1115484442$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1922797615$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$50150675$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$928954932$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$FileAttachmentContentKt { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContent.kt index 49f9f33bdfb..2073d2ed21f 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContent.kt @@ -71,11 +71,11 @@ import io.getstream.chat.android.compose.ui.theme.MessageStyling import io.getstream.chat.android.compose.ui.theme.StreamTokens import io.getstream.chat.android.compose.ui.util.applyIf import io.getstream.chat.android.compose.ui.util.senderAwareDescription -import io.getstream.chat.android.compose.ui.util.shouldBeDisplayedAsFullSizeAttachment import io.getstream.chat.android.compose.viewmodel.messages.AudioPlayerViewModel import io.getstream.chat.android.compose.viewmodel.messages.AudioPlayerViewModelFactory import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.Attachment.UploadState +import io.getstream.chat.android.models.Message import io.getstream.chat.android.previewdata.PreviewAttachmentData import io.getstream.chat.android.ui.common.state.messages.list.AudioPlayerState import io.getstream.chat.android.ui.common.utils.MediaStringUtil @@ -94,16 +94,44 @@ public fun AudioRecordAttachmentContent( viewModelFactory: AudioPlayerViewModelFactory, ) { val viewModel = viewModel(AudioPlayerViewModel::class.java, factory = viewModelFactory) + val playerState by viewModel.state.collectAsStateWithLifecycle() + + AudioRecordAttachmentContentItems( + modifier = modifier, + attachmentState = attachmentState, + playerState = playerState, + onPlayToggleClick = viewModel::playOrPause, + onPlaySpeedClick = viewModel::changeSpeed, + onThumbDragStart = viewModel::startSeek, + onThumbDragStop = viewModel::seekTo, + ) + // Cleanup: Pause any playing tracks in onPause. + LifecycleEventEffect(event = Lifecycle.Event.ON_PAUSE) { + // Important: This effect is disposed when the parent composable is disposed. A side effect of this is that if + // the AudioRecordAttachmentContent is shown in LazyList, and is scrolled away, the effect is disposed and the + // lifecycle event is not received. Therefore, the audio needs to be paused higher in the hierarchy. + viewModel.pause() + } +} + +@Composable +private fun AudioRecordAttachmentContentItems( + attachmentState: AttachmentState, + playerState: AudioPlayerState, + modifier: Modifier = Modifier, + onPlayToggleClick: (Attachment) -> Unit = {}, + onPlaySpeedClick: (Attachment) -> Unit = {}, + onThumbDragStart: (Attachment) -> Unit = {}, + onThumbDragStop: (Attachment, Float) -> Unit = { _, _ -> }, +) { val audioRecordings = attachmentState.message.attachments .filter { attachment -> val attachmentUrl = attachment.assetUrl ?: attachment.upload?.toUri()?.toString() attachment.isAudioRecording() && attachmentUrl != null } - val playerState by viewModel.state.collectAsStateWithLifecycle() - - val shouldBeFullSize = attachmentState.message.shouldBeDisplayedAsFullSizeAttachment() + val hasCaption = attachmentState.message.text.isNotEmpty() // Mirror the multi-attachment grid: a non-clickable wrapper carries the "Voice message" label // (and the sender when this is the sender-bearing attachment) so the message row announces it, // while the playback controls stay individually focusable. @@ -116,12 +144,12 @@ public fun AudioRecordAttachmentContent( }, ) { Column( - modifier = modifier.applyIf(!shouldBeFullSize) { padding(MessageStyling.messageSectionPadding) }, + modifier = modifier.applyIf(hasCaption) { padding(MessageStyling.messageSectionPadding) }, verticalArrangement = Arrangement.spacedBy(MessageStyling.sectionsDistance), ) { audioRecordings.forEach { audioRecording -> AudioRecordAttachmentContentItem( - modifier = Modifier.applyIf(!shouldBeFullSize) { + modifier = Modifier.applyIf(hasCaption) { background( MessageStyling.attachmentBackgroundColor(attachmentState.isMine), RoundedCornerShape(StreamTokens.radiusLg), @@ -130,22 +158,14 @@ public fun AudioRecordAttachmentContent( attachment = audioRecording, playerState = playerState, isMine = attachmentState.isMine, - onPlayToggleClick = viewModel::playOrPause, - onPlaySpeedClick = viewModel::changeSpeed, - onThumbDragStart = viewModel::startSeek, - onThumbDragStop = viewModel::seekTo, + onPlayToggleClick = onPlayToggleClick, + onPlaySpeedClick = onPlaySpeedClick, + onThumbDragStart = onThumbDragStart, + onThumbDragStop = onThumbDragStop, ) } } } - - // Cleanup: Pause any playing tracks in onPause. - LifecycleEventEffect(event = Lifecycle.Event.ON_PAUSE) { - // Important: This effect is disposed when the parent composable is disposed. A side effect of this is that if - // the AudioRecordAttachmentContent is shown in LazyList, and is scrolled away, the effect is disposed and the - // lifecycle event is not received. Therefore, the audio needs to be paused higher in the hierarchy. - viewModel.pause() - } } /** @@ -349,9 +369,11 @@ private fun UploadProgressIndicator( private fun progressFraction(state: UploadState.InProgress): Float = if (state.totalBytes > 0) (state.bytesUploaded / state.totalBytes.toFloat()) else 0f +private const val PreviewAudioAssetUrl = "preview://audio" + @Composable internal fun AudioRecordAttachmentContentItemPlayback() { - val previewUri = "preview://audio" + val previewUri = PreviewAudioAssetUrl AudioRecordAttachmentContentItem( attachment = PreviewAttachmentData.attachmentAudioRecording1.copy(assetUrl = previewUri), playerState = AudioPlayerState( @@ -369,7 +391,7 @@ internal fun AudioRecordAttachmentContentItemPlayback() { internal fun AudioRecordAttachmentContentItemUploading() { AudioRecordAttachmentContentItem( attachment = PreviewAttachmentData.attachmentAudioRecording1.copy( - assetUrl = "preview://audio", + assetUrl = PreviewAudioAssetUrl, uploadState = UploadState.InProgress( bytesUploaded = 2_400_000, totalBytes = 4_000_000, @@ -381,7 +403,7 @@ internal fun AudioRecordAttachmentContentItemUploading() { @Preview(showBackground = true) @Composable -internal fun AudioRecordAttachmentContentItemPreview() { +private fun AudioRecordAttachmentContentItemPreview() { ChatPreviewTheme { AudioRecordAttachmentContentItemPlayback() } @@ -389,8 +411,48 @@ internal fun AudioRecordAttachmentContentItemPreview() { @Preview(showBackground = true) @Composable -internal fun AudioRecordAttachmentContentItemUploadingPreview() { +private fun AudioRecordAttachmentContentItemUploadingPreview() { ChatPreviewTheme { AudioRecordAttachmentContentItemUploading() } } + +@Composable +internal fun AudioRecordAttachmentReplyNoCaptionContent() { + AudioRecordAttachmentReplyContent(hasCaption = false) +} + +@Composable +internal fun AudioRecordAttachmentReplyCaptionContent() { + AudioRecordAttachmentReplyContent(hasCaption = true) +} + +@Composable +private fun AudioRecordAttachmentReplyContent(hasCaption: Boolean) { + val attachment = PreviewAttachmentData.attachmentAudioRecording1.copy(assetUrl = PreviewAudioAssetUrl) + val message = Message( + text = if (hasCaption) "Caption" else "", + attachments = listOf(attachment), + replyTo = Message(), + ) + AudioRecordAttachmentContentItems( + attachmentState = AttachmentState(message = message), + playerState = AudioPlayerState(getRecordingUri = Attachment::assetUrl), + ) +} + +@Preview(showBackground = true) +@Composable +private fun AudioRecordAttachmentReplyNoCaptionPreview() { + ChatPreviewTheme { + AudioRecordAttachmentReplyNoCaptionContent() + } +} + +@Preview(showBackground = true) +@Composable +private fun AudioRecordAttachmentReplyCaptionPreview() { + ChatPreviewTheme { + AudioRecordAttachmentReplyCaptionContent() + } +} 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 752cf4e0fcb..d75e59b2de6 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 @@ -303,7 +303,9 @@ internal fun DefaultMessageRegularContent( ) } - if (!info.displaysFullSizeAttachment) { + val recordingSuppliesBottomPadding = + info.hasRecordings && !info.hasUnknown && message.text.isEmpty() + if (!info.displaysFullSizeAttachment && !recordingSuppliesBottomPadding) { Spacer(Modifier.height(MessageStyling.contentPadding)) } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/content/AttachmentsContentTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/content/AttachmentsContentTest.kt index fc229d003ba..1c63e3d5002 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/content/AttachmentsContentTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/content/AttachmentsContentTest.kt @@ -120,6 +120,20 @@ internal class AttachmentsContentTest : PaparazziComposeTest { } } + @Test + fun `audio record reply without caption`() { + snapshotWithDarkMode { + AudioRecordAttachmentReplyNoCaptionContent() + } + } + + @Test + fun `audio record reply with caption`() { + snapshotWithDarkMode { + AudioRecordAttachmentReplyCaptionContent() + } + } + @Test fun `unsupported attachment content`() { snapshotWithDarkMode { diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/list/VoiceMessageReplyContentTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/list/VoiceMessageReplyContentTest.kt new file mode 100644 index 00000000000..f55832b3ab1 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/list/VoiceMessageReplyContentTest.kt @@ -0,0 +1,100 @@ +/* + * 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.messages.list + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.getstream.chat.android.client.test.MockedChatClientTest +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.util.extensions.toSet +import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.models.AttachmentType +import io.getstream.chat.android.models.ChannelCapabilities +import io.getstream.chat.android.models.ConnectionState +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomUser +import io.getstream.chat.android.ui.common.state.messages.list.MessageItemState +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +internal class VoiceMessageReplyContentTest : MockedChatClientTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun prepare() { + whenever(mockClientState.user) doReturn MutableStateFlow(randomUser()) + whenever(mockClientState.connectionState) doReturn MutableStateFlow(ConnectionState.Connected) + } + + @Test + fun `voice message reply without a caption renders`() { + setMessageContent(voiceReply(text = "")) + + composeTestRule.onNodeWithTag("Stream_MessageCell").assertIsDisplayed() + } + + @Test + fun `voice message reply with a caption renders`() { + setMessageContent(voiceReply(text = "Nice")) + + composeTestRule.onNodeWithTag("Stream_MessageCell").assertIsDisplayed() + } + + @Test + fun `voice message reply with a custom attachment renders`() { + setMessageContent(voiceReply(text = "", extra = Attachment(type = "custom"))) + + composeTestRule.onNodeWithTag("Stream_MessageCell").assertIsDisplayed() + } + + private fun voiceReply(text: String, extra: Attachment? = null) = MessageItemState( + message = Message( + id = "m", + text = text, + attachments = listOfNotNull( + Attachment(type = AttachmentType.AUDIO_RECORDING, assetUrl = "https://example.com/audio.aac"), + extra, + ), + user = User(id = "me"), + replyTo = Message(id = "q", text = "Original", user = User(id = "u2")), + ), + isMine = true, + currentUser = User(id = "me"), + ownCapabilities = ChannelCapabilities.toSet(), + ) + + private fun setMessageContent(messageItem: MessageItemState) { + composeTestRule.setContent { + ChatTheme { + MessageContainer(messageItem = messageItem, onLongItemClick = {}) + } + } + } +} diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_reply_with_caption.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_reply_with_caption.png new file mode 100644 index 00000000000..d321d801108 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_reply_with_caption.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_reply_without_caption.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_reply_without_caption.png new file mode 100644 index 00000000000..3a2ab16778b Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_reply_without_caption.png differ