Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Comment thread
gpunto marked this conversation as resolved.
// 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.
Expand All @@ -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),
Expand All @@ -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()
}
}

/**
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -381,16 +403,56 @@ internal fun AudioRecordAttachmentContentItemUploading() {

@Preview(showBackground = true)
@Composable
internal fun AudioRecordAttachmentContentItemPreview() {
private fun AudioRecordAttachmentContentItemPreview() {
ChatPreviewTheme {
AudioRecordAttachmentContentItemPlayback()
}
}

@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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {})
}
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading