diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt index 4cce4e5ce45..e23f930d0ac 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt @@ -335,10 +335,10 @@ class UserRobot { return this } - fun uploadAttachment(type: AttachmentType, multiple: Boolean = false, send: Boolean = true): UserRobot { + fun attachFile(type: AttachmentType, multiple: Boolean = false): UserRobot { val count = if (multiple) 2 else 1 + Composer.attachmentsButton.waitToAppear().click() repeat(count) { - Composer.attachmentsButton.waitToAppear().click() AttachmentPicker.filesTab.waitToAppear().click() AttachmentPicker.findFilesButton.waitToAppear().click() @@ -351,19 +351,12 @@ class UserRobot { .click() } - if (type == AttachmentType.FILE) AttachmentPicker.pdf1 else AttachmentPicker.image1 - - if (it == 0) { - val attachment = if (type == AttachmentType.FILE) AttachmentPicker.pdf1 else AttachmentPicker.image1 - attachment.waitToAppear().click() + val attachment = if (it == 0) { + if (type == AttachmentType.FILE) AttachmentPicker.pdf1 else AttachmentPicker.image1 } else { - val attachment = if (type == AttachmentType.FILE) AttachmentPicker.pdf2 else AttachmentPicker.image2 - attachment.waitToAppear().click() + if (type == AttachmentType.FILE) AttachmentPicker.pdf2 else AttachmentPicker.image2 } - } - - if (send) { - Composer.sendButton.waitToAppear().click() + attachment.waitToAppear().click() } return this diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AttachmentsTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AttachmentsTests.kt index 9144aeb9252..f125cc77a9c 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AttachmentsTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AttachmentsTests.kt @@ -39,7 +39,7 @@ class AttachmentsTests : StreamTestCase() { userRobot.login().openChannel() } step("WHEN user attaches an image") { - userRobot.uploadAttachment(type = AttachmentType.IMAGE, send = false) + userRobot.attachFile(type = AttachmentType.IMAGE) } step("THEN image is displayed in preview") { userRobot.assertMediaAttachmentInPreview(isDisplayed = true) @@ -59,7 +59,7 @@ class AttachmentsTests : StreamTestCase() { userRobot.login().openChannel() } step("WHEN user attaches multiple images") { - userRobot.uploadAttachment(type = AttachmentType.IMAGE, multiple = true, send = false) + userRobot.attachFile(type = AttachmentType.IMAGE, multiple = true) } step("THEN images are displayed in preview") { userRobot.assertMediaAttachmentInPreview(isDisplayed = true, count = 2) @@ -79,7 +79,10 @@ class AttachmentsTests : StreamTestCase() { userRobot.login().openChannel() } step("WHEN user sends an image") { - userRobot.uploadAttachment(type = AttachmentType.IMAGE) + userRobot.attachFile(type = AttachmentType.IMAGE) + } + step("AND user sends the image") { + userRobot.tapOnSendButton() } step("AND user deletes an image") { userRobot.deleteMessage() @@ -98,7 +101,7 @@ class AttachmentsTests : StreamTestCase() { userRobot.login().openChannel() } step("WHEN user sends a file") { - userRobot.uploadAttachment(type = AttachmentType.FILE, send = false) + userRobot.attachFile(type = AttachmentType.FILE) } step("THEN file is displayed in preview") { userRobot.assertFileAttachmentInPreview(isDisplayed = true) @@ -118,7 +121,7 @@ class AttachmentsTests : StreamTestCase() { userRobot.login().openChannel() } step("WHEN user attaches multiple files") { - userRobot.uploadAttachment(type = AttachmentType.FILE, multiple = true, send = false) + userRobot.attachFile(type = AttachmentType.FILE, multiple = true) } step("THEN files are displayed in preview") { userRobot.assertFileAttachmentInPreview(isDisplayed = true, count = 2) @@ -137,15 +140,18 @@ class AttachmentsTests : StreamTestCase() { step("GIVEN user opens the channel") { userRobot.login().openChannel() } - step("WHEN user sends a file") { - userRobot.uploadAttachment(type = AttachmentType.IMAGE) + step("WHEN user attaches a file") { + userRobot.attachFile(type = AttachmentType.FILE) + } + step("AND user sends the file") { + userRobot.tapOnSendButton() } step("AND user deletes a file") { userRobot.deleteMessage() } step("THEN user can see deleted message") { userRobot - .assertImage(isDisplayed = false) + .assertFile(isDisplayed = false) .assertDeletedMessage() } } diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt index 6fbb6fe16b2..cb90952582c 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt @@ -34,6 +34,7 @@ class CustomSettings(private val context: Context) { var isAdaptiveLayoutEnabled: Boolean by booleanPref(AdaptiveLayout) var isComposerFloatingStyleEnabled: Boolean by booleanPref(ComposerFloatingStyle) + var isSystemAttachmentPickerEnabled: Boolean by booleanPref(SystemAttachmentPicker) private fun booleanPref(key: String, default: Boolean = false) = object : ReadWriteProperty { @@ -47,5 +48,6 @@ class CustomSettings(private val context: Context) { private const val AdaptiveLayout = "adaptive_layout" private const val ComposerFloatingStyle = "composer_floating_style" +private const val SystemAttachmentPicker = "system_attachment_picker" fun Context.customSettings(): CustomSettings = CustomSettings(this) diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt index 01bcb6ff8f0..340d0039f58 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt @@ -110,7 +110,7 @@ class MessagesActivity : ComponentActivity() { floatingStyleEnabled = settings.isComposerFloatingStyleEnabled, ), translation = TranslationConfig(enabled = ChatApp.autoTranslationEnabled), - attachmentPicker = AttachmentPickerConfig(useSystemPicker = false), + attachmentPicker = AttachmentPickerConfig(useSystemPicker = settings.isSystemAttachmentPickerEnabled), ), allowUIAutomationTest = true, messageComposerTheme = messageComposerTheme, diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt index c4e38edf640..61afe6eee61 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt @@ -127,6 +127,9 @@ class CustomLoginActivity : AppCompatActivity() { var isComposerFloatingStyleEnabled by remember { mutableStateOf(settings.isComposerFloatingStyleEnabled) } + var isSystemAttachmentPickerEnabled by remember { + mutableStateOf(settings.isSystemAttachmentPickerEnabled) + } val isLoginButtonEnabled = apiKeyText.isNotEmpty() && userIdText.isNotEmpty() && @@ -153,6 +156,17 @@ class CustomLoginActivity : AppCompatActivity() { settings.isComposerFloatingStyleEnabled = it }, ), + FeatureFlag( + label = stringResource(R.string.custom_login_flag_system_attachment_picker_label), + description = stringResource( + R.string.custom_login_flag_system_attachment_picker_description, + ), + value = isSystemAttachmentPickerEnabled, + onValueChange = { + isSystemAttachmentPickerEnabled = it + settings.isSystemAttachmentPickerEnabled = it + }, + ), ) CustomLoginInputField( diff --git a/stream-chat-android-compose-sample/src/main/res/values/strings.xml b/stream-chat-android-compose-sample/src/main/res/values/strings.xml index f840739ddf0..487a74bdf20 100644 --- a/stream-chat-android-compose-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-compose-sample/src/main/res/values/strings.xml @@ -37,6 +37,8 @@ Adjust layout based on screen sizes Message composer floating style Message list scrolls behind some composer elements + System attachment picker + Use the system\'s native file/media picker Pinned Messages 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 1691c350597..30fbe678ec9 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4339,13 +4339,10 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/Attachme public static final field $stable I public fun (Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;)V public synthetic fun (Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun clearSelection ()V - public final fun deselectAttachment (Lio/getstream/chat/android/models/Attachment;)V public final fun getAttachments ()Ljava/util/List; public final fun getAttachmentsFromMetadata (Ljava/util/List;)Ljava/util/List; public final fun getChannel ()Lio/getstream/chat/android/models/Channel; public final fun getPickerMode ()Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentPickerMode; - public final fun getSelectedAttachments ()Ljava/util/List; public final fun getSubmittedAttachments ()Lkotlinx/coroutines/flow/Flow; public final fun isPickerVisible ()Z public final fun loadAttachments ()V @@ -4353,8 +4350,6 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/Attachme public final fun setPickerMode (Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentPickerMode;)V public final fun setPickerVisible (Z)V public final fun togglePickerVisibility ()V - public final fun toggleSelection (Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentPickerItemState;Z)V - public static synthetic fun toggleSelection$default (Lio/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel;Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentPickerItemState;ZILjava/lang/Object;)V } public final class io/getstream/chat/android/compose/viewmodel/messages/AudioPlayerViewModel : androidx/lifecycle/ViewModel { @@ -4372,12 +4367,13 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/AudioPla public final class io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel : androidx/lifecycle/ViewModel { public static final field $stable I public fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController;Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;)V - public final fun addSelectedAttachments (Ljava/util/List;)V + public final fun addAttachments (Ljava/util/List;)V public final fun buildNewMessage (Ljava/lang/String;Ljava/util/List;)Lio/getstream/chat/android/models/Message; public static synthetic fun buildNewMessage$default (Lio/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/chat/android/models/Message; public final fun cancelLinkPreview ()V public final fun cancelRecording ()V public final fun clearActiveCommand ()V + public final fun clearAttachments ()V public final fun clearData ()V public final fun completeRecording ()V public final fun createPoll (Lio/getstream/chat/android/models/PollConfig;)V @@ -4398,7 +4394,7 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/MessageC public final fun lockRecording ()V public final fun pauseRecording ()V public final fun performMessageAction (Lio/getstream/chat/android/ui/common/state/messages/MessageAction;)V - public final fun removeSelectedAttachment (Lio/getstream/chat/android/models/Attachment;)V + public final fun removeAttachment (Lio/getstream/chat/android/models/Attachment;)V public final fun seekRecordingTo (F)V public final fun selectCommand (Lio/getstream/chat/android/models/Command;)V public final fun selectMention (Lio/getstream/chat/android/models/User;)V @@ -4414,7 +4410,6 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/MessageC public final fun stopRecording ()V public final fun toggleCommandsVisibility ()V public final fun toggleRecordingPlayback ()V - public final fun updateSelectedAttachments (Ljava/util/List;)V } public final class io/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel : androidx/lifecycle/ViewModel { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt index eca6e8254d8..357befa0dee 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt @@ -72,6 +72,7 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.ReactionSorting import io.getstream.chat.android.models.ReactionSortingByFirstReactionAt import io.getstream.chat.android.models.User +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.state.messages.Delete import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.Flag @@ -350,8 +351,10 @@ internal fun DefaultBottomBarContent( isAttachmentPickerVisible = attachmentsPickerViewModel.isPickerVisible, onAttachmentsClick = attachmentsPickerViewModel::togglePickerVisibility, onAttachmentRemoved = { attachment -> - composerViewModel.removeSelectedAttachment(attachment) - attachmentsPickerViewModel.deselectAttachment(attachment) + attachment.extraData[EXTRA_SOURCE_URI] + ?.let { it as? String } + ?.let(attachmentsPickerViewModel::removeFromSelection) + composerViewModel.removeAttachment(attachment) }, onCancelAction = { listViewModel.dismissAllMessageActions() diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt index 07f1e73a8ed..734eb74d4d4 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt @@ -23,15 +23,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import io.getstream.chat.android.compose.R -import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerMode -import io.getstream.chat.android.compose.state.messages.attachments.CameraPickerMode -import io.getstream.chat.android.compose.state.messages.attachments.FilePickerMode -import io.getstream.chat.android.compose.state.messages.attachments.GalleryPickerMode -import io.getstream.chat.android.compose.state.messages.attachments.PollPickerMode import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.viewmodel.messages.AttachmentsPickerViewModel import io.getstream.chat.android.ui.common.state.messages.MessageMode @@ -70,7 +66,9 @@ public fun AttachmentPicker( attachmentsPickerViewModel: AttachmentsPickerViewModel, modifier: Modifier = Modifier, messageMode: MessageMode = MessageMode.Normal, - actions: AttachmentPickerActions = AttachmentPickerActions.pickerDefaults(attachmentsPickerViewModel), + actions: AttachmentPickerActions = remember(attachmentsPickerViewModel) { + AttachmentPickerActions.pickerDefaults(attachmentsPickerViewModel) + }, ) { BackHandler(onBack = actions.onDismiss) @@ -130,17 +128,3 @@ public fun AttachmentPicker( } } } - -/** - * A helper property to check if the current [AttachmentPickerMode] supports multiple selections. - * - * This will return: - * - `true` or `false` for [FilePickerMode] and [GalleryPickerMode], based on their `allowMultipleSelection` property. - * - `null` for other modes like [CameraPickerMode] or [PollPickerMode] that do not support this concept. - */ -internal val AttachmentPickerMode.allowMultipleSelection: Boolean? - get() = when (this) { - is FilePickerMode -> allowMultipleSelection - is GalleryPickerMode -> allowMultipleSelection - else -> null - } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt index a80e7799b23..74ed41222fc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.compose.ui.messages.attachments +import androidx.compose.runtime.Stable import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerItemState import io.getstream.chat.android.compose.viewmodel.messages.AttachmentsPickerViewModel import io.getstream.chat.android.compose.viewmodel.messages.MessageComposerViewModel @@ -27,10 +28,11 @@ import io.getstream.chat.android.models.PollConfig * Actions that can be performed in the attachment picker. * * Each property maps a user gesture or lifecycle event to a handler. - * Override individual actions via [copy] to customise behaviour while keeping the rest at their defaults. + * To customise individual actions, construct a new instance overriding only the properties you need, + * using the companion object factories as a starting point. * * @property onAttachmentItemSelected Called when a user taps an attachment item to select or deselect it - * inside the in-app picker grid. + * inside the in-app attachment browser. * @property onAttachmentsSelected Called when attachments are confirmed and should be added to the composer. * Receives the list of [Attachment] objects ready to be sent. Triggered by system pickers, camera, and * file browser results. @@ -42,6 +44,7 @@ import io.getstream.chat.android.models.PollConfig * @property onCommandSelected Called when the user selects a slash command from the command picker. * @property onDismiss Called when the attachment picker should be dismissed (back press, outside tap, etc.). */ +@Stable public data class AttachmentPickerActions( val onAttachmentItemSelected: (AttachmentPickerItemState) -> Unit, val onAttachmentsSelected: (List) -> Unit, @@ -69,8 +72,8 @@ public data class AttachmentPickerActions( /** * Lightweight defaults suitable for standalone [AttachmentPicker] usage without a composer. * - * Handles picker-level concerns only: toggling selection state and dismissing the picker. - * Poll, command, and attachment-submission actions are no-ops. + * Handles picker-level concerns only: toggling the selection index and dismissing the + * picker. Attachment submission, poll, and command actions are no-ops. * * @param attachmentsPickerViewModel The [AttachmentsPickerViewModel] that drives picker state. */ @@ -78,10 +81,9 @@ public data class AttachmentPickerActions( attachmentsPickerViewModel: AttachmentsPickerViewModel, ): AttachmentPickerActions = AttachmentPickerActions( onAttachmentItemSelected = { item -> - val multiSelect = attachmentsPickerViewModel.pickerMode?.allowMultipleSelection == true - attachmentsPickerViewModel.toggleSelection(item, multiSelect) + handlePickerItemSelection(item, attachmentsPickerViewModel) }, - onAttachmentsSelected = { attachmentsPickerViewModel.setPickerVisible(visible = false) }, + onAttachmentsSelected = {}, onCreatePollClick = {}, onCreatePoll = {}, onCreatePollDismissed = {}, @@ -92,36 +94,72 @@ public data class AttachmentPickerActions( /** * Default implementation wiring both the picker and composer view models. * - * Handles attachment selection, poll creation, command insertion, and picker dismissal. + * Handles item selection, attachment submission, poll creation, command selection, and dismissal. + * Use this when the [AttachmentPicker] is paired with a [MessageComposerViewModel]. * * @param attachmentsPickerViewModel The [AttachmentsPickerViewModel] that drives picker state. - * @param composerViewModel The [MessageComposerViewModel] that receives selected attachments - * and handles poll creation and command insertion. + * @param composerViewModel The [MessageComposerViewModel] that manages the attachment list for the message. */ public fun defaultActions( attachmentsPickerViewModel: AttachmentsPickerViewModel, composerViewModel: MessageComposerViewModel, ): AttachmentPickerActions = AttachmentPickerActions( onAttachmentItemSelected = { item -> - val multiSelect = attachmentsPickerViewModel.pickerMode?.allowMultipleSelection == true - attachmentsPickerViewModel.toggleSelection(item, multiSelect) - composerViewModel.updateSelectedAttachments(attachmentsPickerViewModel.getSelectedAttachments()) - }, - onAttachmentsSelected = { attachments -> - attachmentsPickerViewModel.setPickerVisible(visible = false) - composerViewModel.addSelectedAttachments(attachments) + handleItemSelection(item, attachmentsPickerViewModel, composerViewModel) }, + onAttachmentsSelected = composerViewModel::addAttachments, onCreatePollClick = {}, onCreatePoll = { pollConfig -> - attachmentsPickerViewModel.setPickerVisible(visible = false) + consumePickerSession(attachmentsPickerViewModel, composerViewModel) composerViewModel.createPoll(pollConfig) }, onCreatePollDismissed = {}, onCommandSelected = { command -> - attachmentsPickerViewModel.setPickerVisible(visible = false) + consumePickerSession(attachmentsPickerViewModel, composerViewModel) composerViewModel.selectCommand(command) }, onDismiss = { attachmentsPickerViewModel.setPickerVisible(visible = false) }, ) } } + +private fun handlePickerItemSelection( + item: AttachmentPickerItemState, + pickerViewModel: AttachmentsPickerViewModel, +) { + val uriString = item.attachmentMetaData.uri?.toString() ?: return + if (item.isSelected) { + pickerViewModel.removeFromSelection(uriString) + } else { + pickerViewModel.selectItem(uriString) + } +} + +private fun handleItemSelection( + item: AttachmentPickerItemState, + pickerViewModel: AttachmentsPickerViewModel, + composerViewModel: MessageComposerViewModel, +) { + val uriString = item.attachmentMetaData.uri?.toString() ?: return + if (item.isSelected) { + pickerViewModel.removeFromSelection(uriString) + composerViewModel.removeAttachmentsByUris(setOf(uriString)) + } else { + val attachment = pickerViewModel + .getAttachmentsFromMetadata(listOf(item.attachmentMetaData)) + .firstOrNull() ?: return + val replaced = pickerViewModel.selectItem(uriString) + composerViewModel.removeAttachmentsByUris(replaced) + composerViewModel.addAttachments(listOf(attachment)) + } +} + +private fun consumePickerSession( + pickerViewModel: AttachmentsPickerViewModel, + composerViewModel: MessageComposerViewModel, +) { + // Polls and commands are mutually exclusive with file attachments — reset both. + pickerViewModel.setPickerVisible(visible = false) + pickerViewModel.clearSelection() + composerViewModel.clearAttachments() +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentTypePicker.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentTypePicker.kt index 0a014db5fde..7d75dd6f2df 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentTypePicker.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentTypePicker.kt @@ -88,7 +88,9 @@ internal fun AttachmentTypePicker( trailingContent() } LaunchedEffect(modes) { - modes.firstOrNull()?.let(onModeSelected) + if (selectedMode == null) { + modes.firstOrNull()?.let(onModeSelected) + } } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt index 53243969e2d..b5b150f6709 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt @@ -97,7 +97,7 @@ public fun MessageComposer( onSendMessage: (Message) -> Unit = { viewModel.sendMessage(it) }, onAttachmentsClick: () -> Unit = {}, onValueChange: (String) -> Unit = { viewModel.setMessageInput(it) }, - onAttachmentRemoved: (Attachment) -> Unit = { viewModel.removeSelectedAttachment(it) }, + onAttachmentRemoved: (Attachment) -> Unit = { viewModel.removeAttachment(it) }, onCancelAction: () -> Unit = { viewModel.dismissMessageActions() }, onLinkPreviewClick: ((LinkPreview) -> Unit)? = null, onCancelLinkPreviewClick: (() -> Unit)? = { viewModel.cancelLinkPreview() }, 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 e1a2e97cc4f..e2715bdf6d0 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 @@ -3828,7 +3828,7 @@ public interface ChatComponentFactory { * * Shows a row of buttons that launch system pickers (photo picker, file browser, camera). * This variant does not require storage permissions since it uses system intents. - * Used when [ChatTheme.attachmentPickerConfig.useSystemPicker] is `true`. + * Used when [ChatTheme.config.attachmentPicker.useSystemPicker] is `true`. * * @param channel Used to check channel capabilities for filtering available modes. * @param messageMode Used to filter modes (e.g., polls disabled in threads). diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt index 87348af1f9f..5d5e3417ef7 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt @@ -34,7 +34,6 @@ import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.Channel import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper -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 kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -48,22 +47,22 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.channels.Channel as CoroutineChannel /** - * ViewModel for the attachment picker. Manages available media and file attachments, - * user selection state, and conversion to uploadable [Attachment] objects. + * ViewModel for the attachment picker. Drives picker tab state, device storage browsing, + * and the `isSelected` checkmarks shown in [attachments]. * - * Media and file items are stored in separate lists so they survive tab switches. - * Selection is centralised in a single [Set] of [Uri]s, so items that appear in both - * tabs (e.g. an image visible in the gallery and the files list) share selection state - * automatically — no explicit cross-tab synchronisation is needed. + * Note: [attachments] reflects only the checkmark selection, not the full attachment list + * staged for the message. The composer attachment list is owned by [MessageComposerViewModel]. * - * Picker visibility and active tab survive Activity destruction (e.g. "Don't keep - * activities") so that pending system picker results are delivered on recreation. + * The active tab and checkmark selection survive process death (e.g. "Don't keep activities"). + * Checkmarks are not reset on hide — they persist until the session is explicitly consumed + * (e.g. after a message is sent, a poll is created, or a command is selected). * * @param storageHelper Provides device storage queries and attachment conversion. * @param channelState Provides the current [ChannelState] for channel-specific configuration. - * @param savedStateHandle Persists picker visibility and mode across Activity recreation. + * @param savedStateHandle Persists picker tab and selection state across process death. */ public class AttachmentsPickerViewModel( private val storageHelper: AttachmentStorageHelper, @@ -92,10 +91,18 @@ public class AttachmentsPickerViewModel( ) private val _mediaItems = MutableStateFlow>(emptyList()) private val _fileItems = MutableStateFlow>(emptyList()) - private val _selectedUris = MutableStateFlow>(linkedSetOf()) + + // URI strings of items checked by the user. Drives isSelected in attachments. + // Separate from the composer's attachment list — shared across gallery and file tabs. + // Persisted so checkmarks survive process death (e.g. camera launch). + private val _selectedUris = MutableStateFlow( + savedStateHandle.get>(KeySelectedUris)?.toSet() ?: emptySet(), + ) private val _isPickerVisible = MutableStateFlow( savedStateHandle[KeyPickerVisible] ?: false, ) + private val _submittedAttachments = CoroutineChannel(capacity = UNLIMITED) + private var loadAttachmentsJob: Job? = null /** * The active picker tab. @@ -108,25 +115,23 @@ public class AttachmentsPickerViewModel( public val isPickerVisible: Boolean by _isPickerVisible.asState(viewModelScope) /** - * The attachment list for the active [pickerMode], with each item's selection state - * reflecting whether it is currently selected. + * The attachment list for the active [pickerMode], with each item's [AttachmentPickerItemState.isSelected] + * reflecting the current picker selection. */ public val attachments: List by combine( _pickerMode, _mediaItems, _fileItems, _selectedUris, - ) { mode, media, files, selected -> + ) { mode, media, files, uris -> val items = when (mode) { is GalleryPickerMode, null -> media is FilePickerMode -> files else -> emptyList() } - items.map { meta -> AttachmentPickerItemState(meta, isSelected = meta.uri in selected) } + items.map { meta -> AttachmentPickerItemState(meta, isSelected = meta.uri?.toString() in uris) } }.asState(viewModelScope, emptyList()) - private val _submittedAttachments = kotlinx.coroutines.channels.Channel(capacity = UNLIMITED) - /** * One-shot events for attachments resolved from system picker URIs. * Collected by the parent composable to submit attachments and show error toasts. @@ -148,17 +153,15 @@ public class AttachmentsPickerViewModel( } /** - * Shows or hides the attachment picker. Hiding clears cached data but preserves the - * current selection so items remain checked when the picker is reopened. - * - * Call [clearSelection] after this to also reset the selection (e.g. after sending a message). + * Shows or hides the attachment picker. Hiding clears cached media data but preserves the + * current selection so checkmarks remain when the picker is reopened. * * @param visible `true` to show the picker, `false` to hide it. */ public fun setPickerVisible(visible: Boolean) { _isPickerVisible.value = visible savedStateHandle[KeyPickerVisible] = visible - if (!visible) clearCachedData() + if (!visible) resetPickerState() } /** @@ -169,68 +172,62 @@ public class AttachmentsPickerViewModel( } /** - * Selects or deselects [item]. + * Selects [uriString] in the picker, respecting the active picker mode's multi-select setting. * - * @param item The attachment item to select or deselect. - * @param allowMultipleSelection When `false`, selecting a new item replaces the - * current selection. Tapping the already-selected item is a no-op. + * In single-select mode, all existing selections are cleared before the new item is selected, + * and the previously selected URIs are returned so callers can react (e.g. remove from the composer). + * In multi-select mode, the item is added to the existing selection and an empty set is returned. + * + * @param uriString The URI string of the item to select. + * @return The set of URI strings that were cleared in single-select mode, or an empty set in multi-select mode. */ - public fun toggleSelection( - item: AttachmentPickerItemState, - allowMultipleSelection: Boolean = true, - ) { - val uri = item.attachmentMetaData.uri ?: return - val currentlySelected = uri in _selectedUris.value - - if (!allowMultipleSelection && currentlySelected) return - - _selectedUris.value = if (currentlySelected) { - _selectedUris.value - uri + internal fun selectItem(uriString: String): Set { + val multiSelect = when (val mode = pickerMode) { + is GalleryPickerMode -> mode.allowMultipleSelection + is FilePickerMode -> mode.allowMultipleSelection + else -> false + } + val replaced = if (!multiSelect) { + _selectedUris.value.also { clearSelection() } } else { - if (allowMultipleSelection) _selectedUris.value + uri else linkedSetOf(uri) + emptySet() } + addToSelection(uriString) + return replaced + } + + private fun addToSelection(uriString: String) { + _selectedUris.value += uriString + savedStateHandle[KeySelectedUris] = ArrayList(_selectedUris.value) } /** - * Deselects the attachment whose content URI matches the [EXTRA_SOURCE_URI] stored - * in [attachment]'s [extraData][Attachment.extraData]. + * Removes [uriString] from the picker selection. Has no effect if not selected. * - * @param attachment The [Attachment] to deselect. + * @param uriString The URI string of the item to deselect. */ - public fun deselectAttachment(attachment: Attachment) { - val sourceUri = (attachment.extraData[EXTRA_SOURCE_URI] as? String) - ?.let(Uri::parse) ?: return - _selectedUris.value -= sourceUri + internal fun removeFromSelection(uriString: String) { + _selectedUris.value -= uriString + savedStateHandle[KeySelectedUris] = ArrayList(_selectedUris.value) } /** - * Returns lightweight preview [Attachment] objects for all selected items across both tabs, - * ordered by the sequence in which the user selected them. - * - * Items that appear in both tabs are deduplicated by URI. - * No file copying is performed; file resolution is deferred to send time - * via [AttachmentStorageHelper.resolveAttachmentFiles]. + * Clears all picker selections. Call this when the selection is consumed + * (e.g. after a message is sent, a poll is created, or a command is selected). */ - public fun getSelectedAttachments(): List { - val allItemsByUri = (_mediaItems.value + _fileItems.value) - .mapNotNull { meta -> meta.uri?.let { it to meta } } - .toMap() - val orderedMeta = _selectedUris.value.mapNotNull(allItemsByUri::get) - return storageHelper.toAttachments(orderedMeta) + internal fun clearSelection() { + _selectedUris.value = emptySet() + savedStateHandle.remove>(KeySelectedUris) } /** - * Converts the given [metaData] into lightweight [Attachment]s. - * - * File resolution is deferred to send time via [AttachmentStorageHelper.resolveAttachmentFiles]. + * Converts the given [metaData] into lightweight [Attachment]s ready to be staged in the composer. * * @param metaData The metadata items to convert. */ public fun getAttachmentsFromMetadata(metaData: List): List = storageHelper.toAttachments(metaData) - private var loadAttachmentsJob: Job? = null - /** * Loads attachment metadata from device storage for the current [pickerMode]. */ @@ -276,15 +273,7 @@ public class AttachmentsPickerViewModel( } } - /** - * Removes all selected URIs. Call this when the associated attachments are consumed - * (e.g. message sent, poll created) so the picker starts fresh on next open. - */ - public fun clearSelection() { - _selectedUris.value = linkedSetOf() - } - - private fun clearCachedData() { + private fun resetPickerState() { _pickerMode.value = null savedStateHandle[KeyPickerMode] = null as String? _mediaItems.value = emptyList() @@ -294,6 +283,7 @@ public class AttachmentsPickerViewModel( private const val KeyPickerVisible = "stream_picker_visible" private const val KeyPickerMode = "stream_picker_mode" +private const val KeySelectedUris = "stream_selected_uris" /** * Event emitted when system picker URIs have been resolved into [Attachment]s. @@ -306,6 +296,8 @@ public data class SubmittedAttachments( val hasUnsupportedFiles: Boolean, ) +// Custom AttachmentPickerMode implementations cannot be serialized; they save as "unknown" +// and restore as null (i.e. the active tab is not preserved across process death for custom modes). private fun AttachmentPickerMode.toSavedKey(): String = when (this) { is GalleryPickerMode -> "gallery" is FilePickerMode -> "file" diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt index 87208828def..ad4498de819 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt @@ -43,9 +43,8 @@ import kotlinx.coroutines.flow.StateFlow /** * ViewModel responsible for handling the composing and sending of messages. * - * It relays all its core actions to a shared data source, as a central place for all the Composer logic. - * Additionally, all the core data that can be reused across our SDKs is available through shared data sources, while - * implementation-specific data is stored in respective in the [ViewModel]. + * Delegates all state management and business logic to [MessageComposerController], + * including persistence of picker selections and edit-mode state across process death. * * @param messageComposerController The controller used to relay all the actions and fetch all the state. * @param storageHelper Resolves deferred attachment files before sending. @@ -153,31 +152,43 @@ public class MessageComposerViewModel( public fun dismissMessageActions(): Unit = messageComposerController.dismissMessageActions() /** - * @see [MessageComposerController.updateSelectedAttachments] + * Adds [attachments] to the staged attachment list. + * + * Attachments are keyed by URI string, preserving insertion order. + * + * @param attachments The attachments to add. */ - public fun updateSelectedAttachments(attachments: List) { - messageComposerController.updateSelectedAttachments(attachments) + public fun addAttachments(attachments: List) { + messageComposerController.addAttachments(attachments) } /** - * Stores the selected attachments from the attachment picker. These will be shown in the UI, - * within the composer component. We upload and send these attachments once the user taps on the - * send button. + * Removes [attachment] from the staged attachment list. * - * @param attachments The attachments to store and show in the composer. + * @param attachment The attachment to remove. */ - public fun addSelectedAttachments(attachments: List): Unit = - messageComposerController.addSelectedAttachments(attachments) + public fun removeAttachment(attachment: Attachment) { + messageComposerController.removeAttachment(attachment) + } /** - * Removes a selected attachment from the list, when the user taps on the cancel/delete button. + * Removes all staged attachments whose URI string key is contained in [uris]. * - * This will update the UI to remove it from the composer component. + * @param uris The URI string keys to remove. + */ + internal fun removeAttachmentsByUris(uris: Set) { + messageComposerController.removeAttachmentsByUris(uris) + } + + /** + * Removes all staged attachments. * - * @param attachment The attachment to remove. + * Call this when the attachments are consumed — for example, after a message is sent, + * a poll is created, or a command is selected. */ - public fun removeSelectedAttachment(attachment: Attachment): Unit = - messageComposerController.removeSelectedAttachment(attachment) + public fun clearAttachments() { + messageComposerController.clearAttachments() + } /** * Creates a poll with the given [pollConfig]. @@ -198,6 +209,7 @@ public class MessageComposerViewModel( * It also dismisses any current message actions. * * @param message The message to send. + * @param callback Invoked when the API call completes. */ public fun sendMessage( message: Message, @@ -271,6 +283,8 @@ public class MessageComposerViewModel( /** * Sets the typing updates buffer. + * + * @param buffer The buffer to use for typing updates. */ public fun setTypingUpdatesBuffer(buffer: TypingUpdatesBuffer) { messageComposerController.typingUpdatesBuffer = buffer diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt index d8ab78e95b9..19087aaf531 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.viewmodel.messages import android.content.ClipboardManager import android.content.Context import androidx.core.net.toUri +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.createSavedStateHandle @@ -49,15 +50,19 @@ import java.io.File /** * Holds all the dependencies needed to build the ViewModels for the Messages Screen. - * Currently builds the [MessageComposerViewModel], [MessageListViewModel] and [AttachmentsPickerViewModel]. - * @param context Used to build the [ClipboardManager]. + * Currently, builds the [MessageComposerViewModel], [MessageListViewModel] and [AttachmentsPickerViewModel]. + * + * @param context Android context used to access system services and device storage. * @param channelId The current channel ID, to load the messages from. * @param messageId The message id to which we want to scroll to when opening the message list. * @param parentMessageId The ID of the parent [Message] if the message we want to scroll to is in a thread. If the * message we want to scroll to is not in a thread, you can pass in a null value. + * @param autoTranslationEnabled Whether auto-translation of messages is enabled. * @param chatClient The client to use for API calls. * @param clientState The current state of the SDK. * @param mediaRecorder The media recorder for async voice messages. + * @param userLookupHandler Handler used to look up users for mention autocomplete. + * @param fileToUriConverter Converts a local [File] to a URI string used as an attachment source. * @param messageLimit The number of messages to load in a single page. * @param clipboardHandler [ClipboardHandler] used to copy messages. * @param enforceUniqueReactions Flag to enforce unique reactions or enable multiple from the same user. @@ -91,7 +96,7 @@ public class MessagesViewModelFactory( private val clipboardHandler: ClipboardHandler = ClipboardHandlerImpl( clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager, autoTranslationEnabled = autoTranslationEnabled, - getCurrentUser = { chatClient.getCurrentUser() }, + getCurrentUser = chatClient::getCurrentUser, ), private val enforceUniqueReactions: Boolean = false, private val maxAttachmentCount: Int = AttachmentConstants.MAX_ATTACHMENTS_COUNT, @@ -120,11 +125,36 @@ public class MessagesViewModelFactory( private val storageHelper by lazy { AttachmentStorageHelper(context) } /** - * The list of factories that can build [ViewModel]s that our Messages feature components use. + * Creates the required [ViewModel] for our use case. + * + * Supports [MessageComposerViewModel], [MessageListViewModel], and [AttachmentsPickerViewModel]. + * [MessageComposerViewModel] and [AttachmentsPickerViewModel] will receive a [SavedStateHandle] + * sourced from [extras], allowing them to survive Activity recreation. + * Throws [IllegalArgumentException] for any other class. + * + * @param modelClass The class of the [ViewModel] to create. + * @param extras [CreationExtras] provided by the [androidx.lifecycle.ViewModelStoreOwner]. + */ + override fun create(modelClass: Class, extras: CreationExtras): T = + createViewModel(modelClass, savedStateHandle = extras.createSavedStateHandle()) + + /** + * Creates the required [ViewModel] for our use case. + * + * Supports [MessageComposerViewModel], [MessageListViewModel], and [AttachmentsPickerViewModel]. + * Throws [IllegalArgumentException] for any other class. + * + * Prefer [create] with [CreationExtras] so that [MessageComposerViewModel] and + * [AttachmentsPickerViewModel] can survive Activity recreation. + * + * @param modelClass The class of the [ViewModel] to create. */ - private val factories: Map, () -> ViewModel> = mapOf( - MessageComposerViewModel::class.java to { - MessageComposerViewModel( + override fun create(modelClass: Class): T = + createViewModel(modelClass, savedStateHandle = null) + + private fun createViewModel(modelClass: Class, savedStateHandle: SavedStateHandle?): T { + val viewModel: ViewModel = when (modelClass) { + MessageComposerViewModel::class.java -> MessageComposerViewModel( messageComposerController = MessageComposerController( chatClient = chatClient, channelState = channelStateFlow, @@ -138,12 +168,11 @@ public class MessagesViewModelFactory( isDraftMessageEnabled = isComposerDraftMessageEnabled, isActiveCommandEnabled = true, ), + savedStateHandle = savedStateHandle ?: SavedStateHandle(), ), storageHelper = storageHelper, ) - }, - MessageListViewModel::class.java to { - MessageListViewModel( + MessageListViewModel::class.java -> MessageListViewModel( MessageListController( cid = channelId, clipboardHandler = clipboardHandler, @@ -165,36 +194,18 @@ public class MessagesViewModelFactory( showThreadSeparatorInEmptyThread = showThreadSeparatorInEmptyThread, ), ) - }, - AttachmentsPickerViewModel::class.java to { - AttachmentsPickerViewModel(storageHelper, channelStateFlow) - }, - ) - - /** - * Creates the required [ViewModel] for our use case, based on the [factories] we provided. - */ - override fun create(modelClass: Class, extras: CreationExtras): T { - if (modelClass == AttachmentsPickerViewModel::class.java) { - @Suppress("UNCHECKED_CAST") - return AttachmentsPickerViewModel( + AttachmentsPickerViewModel::class.java -> AttachmentsPickerViewModel( storageHelper = storageHelper, channelState = channelStateFlow, - savedStateHandle = extras.createSavedStateHandle(), - ) as T - } - return create(modelClass) - } - - /** - * Creates the required [ViewModel] for our use case, based on the [factories] we provided. - */ - override fun create(modelClass: Class): T { - val viewModel: ViewModel = factories[modelClass]?.invoke() - ?: throw IllegalArgumentException( + savedStateHandle = savedStateHandle ?: SavedStateHandle(), + ) + else -> throw IllegalArgumentException( "MessagesViewModelFactory can only create instances of " + - "the following classes: ${factories.keys.joinToString { it.simpleName }}", + "${MessageComposerViewModel::class.java.simpleName}, " + + "${MessageListViewModel::class.java.simpleName}, or " + + "${AttachmentsPickerViewModel::class.java.simpleName}.", ) + } @Suppress("UNCHECKED_CAST") return viewModel as T diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt index 9f09dcd65dd..7979a8f0a09 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.compose.viewmodel.messages import android.net.Uri import io.getstream.chat.android.client.channel.state.ChannelState +import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerItemState import io.getstream.chat.android.compose.state.messages.attachments.FilePickerMode import io.getstream.chat.android.compose.state.messages.attachments.GalleryPickerMode import io.getstream.chat.android.models.Attachment @@ -40,7 +41,6 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.assertInstanceOf -import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -66,7 +66,7 @@ internal class AttachmentsPickerViewModelTest { assertTrue(viewModel.isPickerVisible) assertNull(viewModel.pickerMode) assertEquals(2, viewModel.attachments.size) - assertEquals(0, viewModel.getSelectedAttachments().size) + assertEquals(0, viewModel.attachments.count { it.isSelected }) } @Test @@ -80,22 +80,21 @@ internal class AttachmentsPickerViewModelTest { assertTrue(viewModel.isPickerVisible) assertInstanceOf(viewModel.pickerMode) assertEquals(2, viewModel.attachments.size) - assertEquals(0, viewModel.getSelectedAttachments().size) + assertEquals(0, viewModel.attachments.count { it.isSelected }) } @Test fun `Given images on the file system When selecting an image Should show the selection`() { - whenever(storageHelper.toAttachments(any())) doReturn listOf(Attachment(type = "image")) val viewModel = createViewModel() viewModel.setPickerVisible(visible = true) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) assertTrue(viewModel.isPickerVisible) assertNull(viewModel.pickerMode) assertEquals(2, viewModel.attachments.size) - assertEquals(1, viewModel.getSelectedAttachments().size) + assertEquals(1, viewModel.attachments.count { it.isSelected }) } @Test @@ -104,9 +103,9 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) - viewModel.toggleSelection(viewModel.attachments.last()) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.last()) + viewModel.deselect(viewModel.attachments.first()) assertFalse(viewModel.attachments.first().isSelected) assertTrue(viewModel.attachments.last().isSelected) @@ -123,7 +122,7 @@ internal class AttachmentsPickerViewModelTest { assertFalse(viewModel.isPickerVisible) assertNull(viewModel.pickerMode) assertEquals(0, viewModel.attachments.size) - assertEquals(0, viewModel.getSelectedAttachments().size) + assertEquals(0, viewModel.attachments.count { it.isSelected }) } @Test @@ -147,21 +146,6 @@ internal class AttachmentsPickerViewModelTest { verify(storageHelper, never()).getMediaMetadata() } - @Test - fun `Given selected attachments When getting selected attachments Should map metadata for preview`() { - val expectedAttachments = listOf(Attachment(type = "image")) - whenever(storageHelper.toAttachments(any())) doReturn expectedAttachments - val viewModel = createViewModel() - - viewModel.setPickerVisible(visible = true) - viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) - - val result = viewModel.getSelectedAttachments() - - assertEquals(expectedAttachments, result) - } - @Test fun `Given attachment metadata When getting attachments from metadata Should return attachments`() { val metadata = listOf(imageAttachment1, imageAttachment2) @@ -194,42 +178,15 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) - viewModel.toggleSelection(viewModel.attachments.last()) - - viewModel.deselectAttachment(attachmentWithSourceUri(imageUri1)) - - assertFalse(viewModel.attachments.first().isSelected) - assertTrue(viewModel.attachments.last().isSelected) - } - - @Test - fun `Given single selection mode When selecting another item Should replace selection`() { - val viewModel = createViewModel() - - viewModel.setPickerVisible(visible = true) - viewModel.loadMediaItems(imageAttachment1, imageAttachment2) + viewModel.select(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.last()) - viewModel.toggleSelection(viewModel.attachments.first(), allowMultipleSelection = false) - viewModel.toggleSelection(viewModel.attachments.last(), allowMultipleSelection = false) + viewModel.removeFromSelection(imageUri1.toString()) assertFalse(viewModel.attachments.first().isSelected) assertTrue(viewModel.attachments.last().isSelected) } - @Test - fun `Given single selection mode When clicking selected item Should keep it selected`() { - val viewModel = createViewModel() - - viewModel.setPickerVisible(visible = true) - viewModel.loadMediaItems(imageAttachment1) - - viewModel.toggleSelection(viewModel.attachments.first(), allowMultipleSelection = false) - viewModel.toggleSelection(viewModel.attachments.first(), allowMultipleSelection = false) - - assertTrue(viewModel.attachments.first().isSelected) - } - @Test fun `Given selected images When switching to files tab Should preserve image selections`() { val viewModel = createViewModel() @@ -237,7 +194,7 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.setPickerMode(GalleryPickerMode()) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.setPickerMode(FilePickerMode()) viewModel.loadFileItems(fileAttachment1) @@ -246,27 +203,6 @@ internal class AttachmentsPickerViewModelTest { assertTrue(viewModel.attachments.first().isSelected) } - @Test - fun `Given selections in both tabs When getting selected attachments Should combine from all tabs`() { - whenever(storageHelper.toAttachments(any())) doReturn listOf( - Attachment(type = "image"), - Attachment(type = "file"), - ) - val viewModel = createViewModel() - - viewModel.setPickerVisible(visible = true) - viewModel.setPickerMode(GalleryPickerMode()) - viewModel.loadMediaItems(imageAttachment1) - viewModel.toggleSelection(viewModel.attachments.first()) - - viewModel.setPickerMode(FilePickerMode()) - viewModel.loadFileItems(fileAttachment1) - viewModel.toggleSelection(viewModel.attachments.first()) - - val result = viewModel.getSelectedAttachments() - assertEquals(2, result.size) - } - @Test fun `Given existing selections When reloading items Should preserve selections`() { val viewModel = createViewModel() @@ -274,7 +210,7 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.setPickerMode(GalleryPickerMode()) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) @@ -289,12 +225,12 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.setPickerMode(GalleryPickerMode()) viewModel.loadMediaItems(imageAttachment1) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.setPickerMode(FilePickerMode()) viewModel.loadFileItems(fileAttachment1) - viewModel.deselectAttachment(attachmentWithSourceUri(imageUri1)) + viewModel.removeFromSelection(imageUri1.toString()) viewModel.setPickerMode(GalleryPickerMode()) assertFalse(viewModel.attachments.first().isSelected) @@ -306,7 +242,7 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.setPickerVisible(visible = false) viewModel.setPickerVisible(visible = true) @@ -318,16 +254,15 @@ internal class AttachmentsPickerViewModelTest { @Test fun `Given selections When calling clearSelection Should remove all selections`() { - whenever(storageHelper.toAttachments(any())) doReturn emptyList() val viewModel = createViewModel() viewModel.setPickerVisible(visible = true) viewModel.setPickerMode(GalleryPickerMode()) viewModel.loadMediaItems(imageAttachment1) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.setPickerMode(FilePickerMode()) viewModel.loadFileItems(fileAttachment1) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.clearSelection() @@ -335,7 +270,6 @@ internal class AttachmentsPickerViewModelTest { assertFalse(viewModel.attachments.first().isSelected) viewModel.setPickerMode(FilePickerMode()) assertFalse(viewModel.attachments.first().isSelected) - assertEquals(0, viewModel.getSelectedAttachments().size) } @Test @@ -360,7 +294,7 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.setPickerMode(GalleryPickerMode()) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.setPickerMode(FilePickerMode()) viewModel.loadFileItems(imageAttachment1, fileAttachment1) @@ -380,7 +314,7 @@ internal class AttachmentsPickerViewModelTest { viewModel.loadFileItems(imageAttachment1, fileAttachment1) viewModel.setPickerMode(GalleryPickerMode()) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.setPickerMode(FilePickerMode()) assertTrue(viewModel.attachments.first().isSelected) @@ -397,17 +331,16 @@ internal class AttachmentsPickerViewModelTest { viewModel.loadFileItems(imageAttachment1) viewModel.setPickerMode(GalleryPickerMode()) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) + viewModel.deselect(viewModel.attachments.first()) - viewModel.toggleSelection(viewModel.attachments.first()) assertFalse(viewModel.attachments.first().isSelected) viewModel.setPickerMode(FilePickerMode()) assertFalse(viewModel.attachments.first().isSelected) } @Test - fun `Given same file in both tabs When getting selected attachments Should not duplicate`() { - whenever(storageHelper.toAttachments(any())) doReturn listOf(Attachment(type = "image")) + fun `Given same file in both tabs When selecting Should appear selected in both tabs`() { val viewModel = createViewModel() viewModel.setPickerVisible(visible = true) @@ -417,12 +350,11 @@ internal class AttachmentsPickerViewModelTest { viewModel.loadFileItems(imageAttachment1) viewModel.setPickerMode(GalleryPickerMode()) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) assertTrue(viewModel.attachments.first().isSelected) viewModel.setPickerMode(FilePickerMode()) assertTrue(viewModel.attachments.first().isSelected) - assertEquals(1, viewModel.getSelectedAttachments().size) } @Test @@ -436,9 +368,9 @@ internal class AttachmentsPickerViewModelTest { viewModel.loadFileItems(imageAttachment1) viewModel.setPickerMode(GalleryPickerMode()) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) - viewModel.deselectAttachment(attachmentWithSourceUri(imageUri1)) + viewModel.removeFromSelection(imageUri1.toString()) assertFalse(viewModel.attachments.first().isSelected) viewModel.setPickerMode(FilePickerMode()) @@ -446,23 +378,60 @@ internal class AttachmentsPickerViewModelTest { } @Test - fun `Given multiple images When selecting second then first Should return in selection order`() { - whenever(storageHelper.toAttachments(any())).thenAnswer { invocation -> - @Suppress("UNCHECKED_CAST") - val metadata = invocation.arguments[0] as List - metadata.map { Attachment(type = "image", name = it.title) } - } + fun `Given single-select mode When a second item is selected Then the first is automatically deselected`() { + val viewModel = createViewModel() + + viewModel.setPickerVisible(visible = true) + viewModel.setPickerMode(GalleryPickerMode(allowMultipleSelection = false)) + viewModel.loadMediaItems(imageAttachment1, imageAttachment2) + viewModel.select(viewModel.attachments.first()) + + viewModel.select(viewModel.attachments.last()) + + assertFalse(viewModel.attachments.first().isSelected) + assertTrue(viewModel.attachments.last().isSelected) + } + + @Test + fun `Given single-select mode When selectItem is called Then returns the previously selected URIs`() { + val viewModel = createViewModel() + + viewModel.setPickerVisible(visible = true) + viewModel.setPickerMode(GalleryPickerMode(allowMultipleSelection = false)) + viewModel.loadMediaItems(imageAttachment1, imageAttachment2) + viewModel.select(viewModel.attachments.first()) + + val replaced = viewModel.selectItem(imageUri2.toString()) + + assertEquals(setOf(imageUri1.toString()), replaced) + } + + @Test + fun `Given multi-select gallery mode When selecting multiple items Then all remain selected`() { val viewModel = createViewModel() viewModel.setPickerVisible(visible = true) + viewModel.setPickerMode(GalleryPickerMode(allowMultipleSelection = true)) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.last()) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.last()) - val result = viewModel.getSelectedAttachments() - assertEquals(2, result.size) - assertEquals("img_2.png", result[0].name) - assertEquals("img_1.jpeg", result[1].name) + assertTrue(viewModel.attachments.first().isSelected) + assertTrue(viewModel.attachments.last().isSelected) + } + + @Test + fun `Given multi-select file mode When selecting multiple items Then all remain selected`() { + val viewModel = createViewModel() + + viewModel.setPickerVisible(visible = true) + viewModel.setPickerMode(FilePickerMode(allowMultipleSelection = true)) + viewModel.loadFileItems(fileAttachment1, fileAttachment2) + viewModel.select(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.last()) + + assertTrue(viewModel.attachments.first().isSelected) + assertTrue(viewModel.attachments.last().isSelected) } @Test @@ -552,6 +521,24 @@ internal class AttachmentsPickerViewModelTest { private fun createViewModel(): AttachmentsPickerViewModel = AttachmentsPickerViewModel(storageHelper, channelState) + /** + * Selects an item by adding its URI to the picker selection. + * Assumes the item is not yet selected. + */ + private fun AttachmentsPickerViewModel.select(item: AttachmentPickerItemState) { + val uriString = item.attachmentMetaData.uri?.toString() ?: return + selectItem(uriString) + } + + /** + * Deselects an item by removing its URI from the picker selection. + * Assumes the item is currently selected. + */ + private fun AttachmentsPickerViewModel.deselect(item: AttachmentPickerItemState) { + val uriString = item.attachmentMetaData.uri?.toString() ?: return + removeFromSelection(uriString) + } + /** * Sets media items on the ViewModel by mocking [AttachmentStorageHelper.getMediaMetadata] * and calling [AttachmentsPickerViewModel.loadAttachments]. diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt index bf859b77d31..e7c4861a124 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt @@ -46,6 +46,7 @@ import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Def import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import io.getstream.chat.android.ui.common.feature.messages.composer.mention.MentionType import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.MessageMode import io.getstream.chat.android.ui.common.state.messages.Reply @@ -129,10 +130,10 @@ internal class MessageComposerViewModelTest { .givenSendMessage() .get() - viewModel.addSelectedAttachments( + viewModel.addAttachments( listOf( - Attachment(imageUrl = "url1"), - Attachment(imageUrl = "url2"), + Attachment(imageUrl = "url1", extraData = mapOf(EXTRA_SOURCE_URI to "content://media/1")), + Attachment(imageUrl = "url2", extraData = mapOf(EXTRA_SOURCE_URI to "content://media/2")), ), ) val state = viewModel.messageComposerState.value @@ -164,19 +165,60 @@ internal class MessageComposerViewModelTest { .givenChannelState() .get() - viewModel.addSelectedAttachments( + viewModel.addAttachments( listOf( - Attachment(imageUrl = "url1"), - Attachment(imageUrl = "url2"), + Attachment(imageUrl = "url1", extraData = mapOf(EXTRA_SOURCE_URI to "content://media/1")), + Attachment(imageUrl = "url2", extraData = mapOf(EXTRA_SOURCE_URI to "content://media/2")), ), ) - viewModel.removeSelectedAttachment( - Attachment(imageUrl = "url1"), + viewModel.removeAttachment( + Attachment(imageUrl = "url1", extraData = mapOf(EXTRA_SOURCE_URI to "content://media/1")), ) viewModel.messageComposerState.value.attachments.size `should be equal to` 1 } + @Test + fun `Given staged attachments When removeAttachmentsByUris is called Then matching attachments are removed from state`() = + runTest { + val viewModel = Fixture() + .givenCurrentUser() + .givenChannelQuery() + .givenChannelState() + .get() + + viewModel.addAttachments( + listOf( + Attachment(extraData = mapOf(EXTRA_SOURCE_URI to "content://media/1")), + Attachment(extraData = mapOf(EXTRA_SOURCE_URI to "content://media/2")), + Attachment(extraData = mapOf(EXTRA_SOURCE_URI to "content://media/3")), + ), + ) + viewModel.removeAttachmentsByUris(setOf("content://media/1", "content://media/3")) + + viewModel.messageComposerState.value.attachments.size `should be equal to` 1 + } + + @Test + fun `Given staged attachments When clearAttachments is called Then all attachments are removed from state`() = + runTest { + val viewModel = Fixture() + .givenCurrentUser() + .givenChannelQuery() + .givenChannelState() + .get() + + viewModel.addAttachments( + listOf( + Attachment(extraData = mapOf(EXTRA_SOURCE_URI to "content://media/1")), + Attachment(extraData = mapOf(EXTRA_SOURCE_URI to "content://media/2")), + ), + ) + viewModel.clearAttachments() + + viewModel.messageComposerState.value.attachments.size `should be equal to` 0 + } + @Test fun `Given message composer When checking the also send to channel checkbox Should update the checkbox state`() = runTest { diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactoryTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactoryTest.kt index d258d9bf4a8..87fb78ea73e 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactoryTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactoryTest.kt @@ -77,8 +77,8 @@ internal class MessagesViewModelFactoryTest { } assertEquals( - "MessagesViewModelFactory can only create instances of the following classes: " + - "MessageComposerViewModel, MessageListViewModel, AttachmentsPickerViewModel", + "MessagesViewModelFactory can only create instances of " + + "MessageComposerViewModel, MessageListViewModel, or AttachmentsPickerViewModel.", exception.message, ) } diff --git a/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/guides/AddingCustomAttachments.java b/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/guides/AddingCustomAttachments.java index ed98e794fb5..b8aee6e1516 100644 --- a/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/guides/AddingCustomAttachments.java +++ b/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/guides/AddingCustomAttachments.java @@ -192,7 +192,7 @@ private void setLeadingContent(Context context) { .withType("date") .withExtraData(extraData) .build(); - messageComposerViewModel.addSelectedAttachments(Collections.singletonList(attachment)); + messageComposerViewModel.addAttachments(Collections.singletonList(attachment)); }); // Show the date picker dialog on a click on the calendar button diff --git a/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/messages/MessageComposer.java b/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/messages/MessageComposer.java index 1796348936d..77a9c7d044e 100644 --- a/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/messages/MessageComposer.java +++ b/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/messages/MessageComposer.java @@ -31,7 +31,6 @@ import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserLookupHandler; import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserQueryFilter; import io.getstream.chat.android.ui.common.feature.messages.composer.mention.UserLookupHandler; -import io.getstream.chat.android.ui.common.feature.messages.composer.query.filter.DefaultQueryFilter; import io.getstream.chat.android.ui.common.feature.messages.composer.transliteration.DefaultStreamTransliterator; import io.getstream.chat.android.ui.common.feature.messages.composer.transliteration.StreamTransliterator; import io.getstream.chat.android.ui.common.state.messages.Edit; @@ -321,11 +320,11 @@ public void handlingActions2() { return Unit.INSTANCE; }); messageComposerView.setAttachmentSelectionListener((attachments) -> { - messageComposerViewModel.addSelectedAttachments(attachments); + messageComposerViewModel.addAttachments(attachments); return Unit.INSTANCE; }); messageComposerView.setAttachmentRemovalListener((attachment) -> { - messageComposerViewModel.removeSelectedAttachment(attachment); + messageComposerViewModel.removeAttachment(attachment); return Unit.INSTANCE; }); messageComposerView.setMentionSelectionListener((user) -> { @@ -429,7 +428,7 @@ public void contentCustomization2() { return Unit.INSTANCE; }); centerContent.setAttachmentRemovalListener((attachment -> { - messageComposerViewModel.removeSelectedAttachment(attachment); + messageComposerViewModel.removeAttachment(attachment); return Unit.INSTANCE; })); messageComposerView.setCenterContent(centerContent); diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/guides/AddingCustomAttachments.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/guides/AddingCustomAttachments.kt index 7fb1018ff88..11f1af30087 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/guides/AddingCustomAttachments.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/guides/AddingCustomAttachments.kt @@ -118,7 +118,7 @@ private object AddingCustomAttachmentsSnippet { ) // 3 - composerViewModel.addSelectedAttachments(listOf(attachment)) + composerViewModel.addAttachments(listOf(attachment)) }, ) } diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/AttachmentsPicker.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/AttachmentsPicker.kt index 000739e3ad9..3889b633544 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/AttachmentsPicker.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/AttachmentsPicker.kt @@ -8,6 +8,7 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import io.getstream.chat.android.compose.ui.messages.attachments.AttachmentPicker @@ -47,18 +48,19 @@ private object AttachmentsPickerUsageSnippet { val isPickerVisible = attachmentsPickerViewModel.isPickerVisible if (isPickerVisible) { - AttachmentPicker( // Add the picker to your UI + AttachmentPicker( + // Add the picker to your UI attachmentsPickerViewModel = attachmentsPickerViewModel, - actions = AttachmentPickerActions.pickerDefaults( - attachmentsPickerViewModel, - ).copy( - onAttachmentsSelected = { - // Handle selected attachments - }, - onDismiss = { - // Handle dismiss - }, - ), + actions = remember(attachmentsPickerViewModel) { + AttachmentPickerActions.pickerDefaults(attachmentsPickerViewModel).copy( + onAttachmentsSelected = { + // Handle selected attachments + }, + onDismiss = { + // Handle dismiss + }, + ) + }, ) } } @@ -101,10 +103,12 @@ private object AttachmentsPickerHandlingActionsSnippet { if (isPickerVisible) { AttachmentPicker( attachmentsPickerViewModel = attachmentsPickerViewModel, - actions = AttachmentPickerActions.defaultActions( - attachmentsPickerViewModel, - composerViewModel, - ), + actions = remember(attachmentsPickerViewModel) { + AttachmentPickerActions.defaultActions( + attachmentsPickerViewModel, + composerViewModel, + ) + }, ) } } @@ -143,16 +147,16 @@ private object AttachmentsPickerCustomizationSnippet { if (isPickerVisible) { AttachmentPicker( attachmentsPickerViewModel = attachmentsPickerViewModel, - actions = AttachmentPickerActions.pickerDefaults( - attachmentsPickerViewModel, - ).copy( - onAttachmentsSelected = { attachments -> - // Handle selected attachments - }, - onDismiss = { - // Handle dismiss - }, - ), + actions = remember(attachmentsPickerViewModel) { + AttachmentPickerActions.pickerDefaults(attachmentsPickerViewModel).copy( + onAttachmentsSelected = { attachments -> + // Handle selected attachments + }, + onDismiss = { + // Handle dismiss + }, + ) + }, ) } } diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt index 6e182e1e95d..73fd0684eb8 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt @@ -116,7 +116,7 @@ private object MessageComposerHandlingActionsSnippet { viewModel = viewModel, onSendMessage = viewModel::sendMessage, onValueChange = viewModel::setMessageInput, - onAttachmentRemoved = viewModel::removeSelectedAttachment, + onAttachmentRemoved = viewModel::removeAttachment, onCancelAction = viewModel::dismissMessageActions, onUserSelected = viewModel::selectMention, onCommandSelected = viewModel::selectCommand, @@ -239,9 +239,9 @@ private object MessageComposerCustomizationSnippet { .weight(7f) .padding(start = 8.dp), messageComposerState = inputState, - onValueChange = { composerViewModel.setMessageInput(it) }, - onAttachmentRemoved = { composerViewModel.removeSelectedAttachment(it) }, - onCancelAction = { composerViewModel.dismissMessageActions() }, + onValueChange = composerViewModel::setMessageInput, + onAttachmentRemoved = composerViewModel::removeAttachment, + onCancelAction = composerViewModel::dismissMessageActions, onSendClick = onSendClick, recordingActions = AudioRecordingActions.defaultActions( viewModel = composerViewModel, diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/cookbook/ui/CustomComposerAndAttachmentsPicker.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/cookbook/ui/CustomComposerAndAttachmentsPicker.kt index b9b76077f11..bf14e419ba7 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/cookbook/ui/CustomComposerAndAttachmentsPicker.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/cookbook/ui/CustomComposerAndAttachmentsPicker.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerItemState import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerMode import io.getstream.chat.android.compose.state.messages.attachments.CameraPickerMode import io.getstream.chat.android.compose.state.messages.attachments.FilePickerMode @@ -184,9 +185,9 @@ private fun CustomMessageComposer( } MessageInput( messageComposerState = composerState, - onValueChange = { composerViewModel.setMessageInput(it) }, - onAttachmentRemoved = { composerViewModel.removeSelectedAttachment(it) }, - onCancelAction = { composerViewModel.dismissMessageActions() }, + onValueChange = composerViewModel::setMessageInput, + onAttachmentRemoved = composerViewModel::removeAttachment, + onCancelAction = composerViewModel::dismissMessageActions, onSendClick = onSendClick, recordingActions = AudioRecordingActions.defaultActions( viewModel = composerViewModel, @@ -268,7 +269,11 @@ private fun CustomAttachmentsPicker( isSubmitEnabled = attachmentsPickerViewModel.attachments.any { it.isSelected }, onSubmitClick = { actions.onAttachmentsSelected( - attachmentsPickerViewModel.getSelectedAttachments(), + attachmentsPickerViewModel.getAttachmentsFromMetadata( + attachmentsPickerViewModel.attachments + .filter(AttachmentPickerItemState::isSelected) + .map(AttachmentPickerItemState::attachmentMetaData), + ), ) }, ) diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/guides/AddingCustomAttachments.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/guides/AddingCustomAttachments.kt index c9bef46230a..c87c4072d5d 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/guides/AddingCustomAttachments.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/guides/AddingCustomAttachments.kt @@ -125,7 +125,7 @@ class AddingCustomAttachments { type = "date", extraData = mutableMapOf("payload" to payload) ) - messageComposerViewModel.addSelectedAttachments(listOf(attachment)) + messageComposerViewModel.addAttachments(listOf(attachment)) } // Show the date picker dialog on a click on the calendar button diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/messages/MessageComposer.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/messages/MessageComposer.kt index 7c74e15dc4e..de11d51278c 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/messages/MessageComposer.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/messages/MessageComposer.kt @@ -10,17 +10,14 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider import com.google.android.material.datepicker.MaterialDatePicker import io.getstream.chat.android.client.ChatClient -import io.getstream.chat.android.client.ChatClient.Companion.instance import io.getstream.chat.android.client.channel.ChannelClient import io.getstream.chat.android.models.Filters.eq import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField.Companion.descByName import io.getstream.chat.android.models.querysort.QuerySorter -import io.getstream.chat.android.ui.common.feature.messages.composer.mention.CompatUserLookupHandler import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserLookupHandler import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserQueryFilter import io.getstream.chat.android.ui.common.feature.messages.composer.mention.UserLookupHandler @@ -48,10 +45,7 @@ import io.getstream.chat.android.ui.viewmodel.messages.MessageListViewModelFacto import io.getstream.chat.android.ui.viewmodel.messages.bindView import io.getstream.chat.docs.R import io.getstream.chat.docs.databinding.MessageComposerLeadingContentBinding -import io.getstream.result.Result -import io.getstream.result.call.Call import io.getstream.result.call.map -import kotlin.jvm.functions.Function1 /** * [Message Composer](https://getstream.io/chat/docs/sdk/android/ui/message-components/message-composer) @@ -199,10 +193,10 @@ private object MessageComposer : Fragment() { messageComposerViewModel.setMessageInput(text) } messageComposerView.attachmentSelectionListener = { attachments -> - messageComposerViewModel.addSelectedAttachments(attachments) + messageComposerViewModel.addAttachments(attachments) } messageComposerView.attachmentRemovalListener = { attachment -> - messageComposerViewModel.removeSelectedAttachment(attachment) + messageComposerViewModel.removeAttachment(attachment) } messageComposerView.mentionSelectionListener = { user -> messageComposerViewModel.selectMention(user) @@ -294,7 +288,7 @@ private object MessageComposer : Fragment() { messageComposerView.setLeadingContent( DefaultMessageComposerLeadingContent(context).also { it.attachmentsButtonClickListener = { - // Show attachment dialog and invoke messageComposerViewModel.addSelectedAttachments(attachments) + // Show attachment dialog and invoke messageComposerViewModel.addAttachments(attachments) } it.commandsButtonClickListener = { messageComposerViewModel.toggleCommandsVisibility() } } @@ -303,7 +297,7 @@ private object MessageComposer : Fragment() { DefaultMessageComposerCenterContent(context).also { it.textInputChangeListener = { text -> messageComposerViewModel.setMessageInput(text) } it.attachmentRemovalListener = - { attachment -> messageComposerViewModel.removeSelectedAttachment(attachment) } + { attachment -> messageComposerViewModel.removeAttachment(attachment) } } ) messageComposerView.setTrailingContent( diff --git a/stream-chat-android-ui-common/build.gradle.kts b/stream-chat-android-ui-common/build.gradle.kts index c86e9ea25e2..1bebb5ebac3 100644 --- a/stream-chat-android-ui-common/build.gradle.kts +++ b/stream-chat-android-ui-common/build.gradle.kts @@ -80,6 +80,7 @@ dependencies { testRuntimeOnly(libs.junit.jupiter.engine) testRuntimeOnly(libs.junit.vintage.engine) + testImplementation(libs.json) testImplementation(libs.threetenbp) testImplementation(libs.androidx.core.testing) testImplementation(libs.androidx.test.junit) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepository.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepository.kt new file mode 100644 index 00000000000..388ce700745 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepository.kt @@ -0,0 +1,200 @@ +/* + * 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.ui.common.feature.messages.composer + +import androidx.lifecycle.SavedStateHandle +import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI +import org.json.JSONArray +import org.json.JSONObject + +/** + * Persists and restores the message composer session across process death. + * + * Stores both the picker-selected attachments and the current edit-mode state as a single + * JSON string under one key in the provided [SavedStateHandle]. + */ +internal class ComposerSessionRepository(private val savedStateHandle: SavedStateHandle) { + + /** + * Snapshot of the edit-mode state: the message being edited and its base attachments. + * + * @param message The message currently being edited. + * @param attachments The original attachments of [message]. + */ + data class EditMode(val message: Message, val attachments: List) + + /** + * Saves the current composer session to the [SavedStateHandle]. + * + * Removes the persisted entry when both [selectedAttachments] is empty and [editMode] is null. + * + * @param selectedAttachments Picker selections keyed by source URI. + * @param editMode The current edit-mode state, or `null` if not editing. + */ + fun save(selectedAttachments: List, editMode: EditMode?) { + if (selectedAttachments.isEmpty() && editMode == null) { + savedStateHandle.remove(KEY_SESSION) + return + } + val json = JSONObject() + json.put(KEY_SELECTED_ATTACHMENTS, JSONArray(selectedAttachments.map(::attachmentToJson))) + editMode?.let { mode -> + json.put(KEY_EDIT_MESSAGE, messageToJson(mode.message)) + json.put(KEY_EDIT_ATTACHMENTS, JSONArray(mode.attachments.map(::attachmentToJson))) + } + savedStateHandle[KEY_SESSION] = json.toString() + } + + /** + * Restores the picker-selected attachments from the [SavedStateHandle]. + * + * @return The list of previously selected attachments, or an empty list if none were persisted. + */ + fun restoreSelectedAttachments(): List = + sessionJson()?.optJSONArray(KEY_SELECTED_ATTACHMENTS)?.toAttachmentList() ?: emptyList() + + /** + * Restores the edit-mode state from the [SavedStateHandle]. + * + * @return The previously persisted [EditMode], or `null` if no edit-mode state was saved. + */ + fun restoreEditMode(): EditMode? { + val json = sessionJson() ?: return null + val message = json.optJSONObject(KEY_EDIT_MESSAGE)?.let(::jsonToMessage) ?: return null + val attachments = json.optJSONArray(KEY_EDIT_ATTACHMENTS)?.toAttachmentList() ?: emptyList() + return EditMode(message, attachments) + } + + private fun sessionJson(): JSONObject? { + val str = savedStateHandle.get(KEY_SESSION) ?: return null + return try { JSONObject(str) } catch (_: Exception) { null } + } + + private fun JSONArray.toAttachmentList(): List = + (0 until length()).mapNotNull { jsonToAttachment(getJSONObject(it)) } + + private fun attachmentToJson(attachment: Attachment): JSONObject = JSONObject().apply { + attachment.sourceUriString()?.let { put(KEY_URI, it) } + attachment.type?.let { put(KEY_TYPE, it) } + put(KEY_NAME, attachment.name) + put(KEY_FILE_SIZE, attachment.fileSize) + attachment.mimeType?.let { put(KEY_MIME_TYPE, it) } + attachment.assetUrl?.let { put(KEY_ASSET_URL, it) } + attachment.imageUrl?.let { put(KEY_IMAGE_URL, it) } + attachment.thumbUrl?.let { put(KEY_THUMB_URL, it) } + attachment.title?.let { put(KEY_TITLE, it) } + extraDataToJson(attachment.extraData)?.let { put(KEY_EXTRA_DATA, it) } + } + + private fun jsonToAttachment(json: JSONObject): Attachment? { + val uri = json.optString(KEY_URI).takeIf(String::isNotEmpty) + val name = json.optString(KEY_NAME).takeIf(String::isNotEmpty) + val assetUrl = json.optString(KEY_ASSET_URL).takeIf(String::isNotEmpty) + val imageUrl = json.optString(KEY_IMAGE_URL).takeIf(String::isNotEmpty) + val thumbUrl = json.optString(KEY_THUMB_URL).takeIf(String::isNotEmpty) + val hasIdentifier = uri != null || name != null || assetUrl != null || imageUrl != null || thumbUrl != null + if (!hasIdentifier) return null + val extraData = buildMap { + json.optJSONObject(KEY_EXTRA_DATA)?.let { obj -> + for (key in obj.keys()) put(key, obj.get(key)) + } + if (uri != null) put(EXTRA_SOURCE_URI, uri) + } + return Attachment( + type = json.optString(KEY_TYPE).takeIf(String::isNotEmpty), + name = json.optString(KEY_NAME), + fileSize = json.optInt(KEY_FILE_SIZE), + mimeType = json.optString(KEY_MIME_TYPE).takeIf(String::isNotEmpty), + assetUrl = assetUrl, + imageUrl = imageUrl, + thumbUrl = thumbUrl, + title = json.optString(KEY_TITLE).takeIf(String::isNotEmpty), + extraData = extraData, + ) + } + + private fun messageToJson(message: Message): JSONObject = JSONObject().apply { + put(KEY_MSG_ID, message.id) + put(KEY_MSG_CID, message.cid) + put(KEY_MSG_TEXT, message.text) + message.parentId?.let { put(KEY_MSG_PARENT_ID, it) } + put(KEY_MSG_TYPE, message.type) + } + + private fun jsonToMessage(json: JSONObject): Message? { + val id = json.optString(KEY_MSG_ID).takeIf(String::isNotEmpty) ?: return null + val cid = json.optString(KEY_MSG_CID).takeIf(String::isNotEmpty) ?: return null + return Message( + id = id, + cid = cid, + text = json.optString(KEY_MSG_TEXT), + parentId = json.optString(KEY_MSG_PARENT_ID).takeIf(String::isNotEmpty), + type = json.optString(KEY_MSG_TYPE), + ) + } + + /** + * Serializes [extraData] to a [JSONObject], excluding [EXTRA_SOURCE_URI] (stored separately). + * + * Only primitive types (String, Boolean, Int, Long, Double, Float) are included. + * Non-serializable values are silently skipped. Note that [Float] values are promoted to + * [Double] during serialization because JSON has no single-precision type; callers should + * read restored values as [Number] rather than casting directly to [Float]. + * + * @return A [JSONObject], or `null` if there are no serializable entries. + */ + private fun extraDataToJson(extraData: Map): JSONObject? { + val json = JSONObject() + for ((key, value) in extraData) { + if (key == EXTRA_SOURCE_URI) continue + when (value) { + is String -> json.put(key, value) + is Boolean -> json.put(key, value) + is Int -> json.put(key, value) + is Long -> json.put(key, value) + is Double -> json.put(key, value) + is Float -> json.put(key, value.toDouble()) + } + } + return if (json.length() > 0) json else null + } + + private fun Attachment.sourceUriString(): String? = extraData[EXTRA_SOURCE_URI]?.toString() +} + +private const val KEY_SESSION = "stream_composer_session" +private const val KEY_SELECTED_ATTACHMENTS = "stream_composer_selected_attachments" +private const val KEY_EDIT_MESSAGE = "stream_composer_edit_message" +private const val KEY_EDIT_ATTACHMENTS = "stream_composer_edit_attachments" + +private const val KEY_URI = "uri" +private const val KEY_TYPE = "type" +private const val KEY_NAME = "name" +private const val KEY_FILE_SIZE = "fileSize" +private const val KEY_MIME_TYPE = "mimeType" +private const val KEY_ASSET_URL = "assetUrl" +private const val KEY_IMAGE_URL = "imageUrl" +private const val KEY_THUMB_URL = "thumbUrl" +private const val KEY_TITLE = "title" +private const val KEY_EXTRA_DATA = "extraData" +private const val KEY_MSG_ID = "msgId" +private const val KEY_MSG_CID = "msgCid" +private const val KEY_MSG_TEXT = "msgText" +private const val KEY_MSG_PARENT_ID = "msgParentId" +private const val KEY_MSG_TYPE = "msgType" diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index 2579581c51f..85e39be78ab 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.ui.common.feature.messages.composer +import androidx.lifecycle.SavedStateHandle import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.state.GlobalState import io.getstream.chat.android.client.api.state.globalStateFlow @@ -37,6 +38,7 @@ import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Men import io.getstream.chat.android.ui.common.feature.messages.composer.mention.UserLookupHandler import io.getstream.chat.android.ui.common.feature.messages.composer.typing.TypingSuggester import io.getstream.chat.android.ui.common.feature.messages.composer.typing.TypingSuggestionOptions +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.MessageAction import io.getstream.chat.android.ui.common.state.messages.MessageInput @@ -107,6 +109,8 @@ import java.util.regex.Pattern * @param fileToUri The function used to convert a file to a URI. * @param config The configuration for the message composer. * @param globalState A flow emitting the current [GlobalState]. + * @param savedStateHandle Handle used to persist and restore picker selections and edit-mode state + * across process death (e.g. caused by opening the system file picker while editing a message). */ @OptIn(ExperimentalCoroutinesApi::class) @InternalStreamChatApi @@ -120,6 +124,7 @@ public class MessageComposerController( fileToUri: (File) -> String, private val config: Config = Config(), private val globalState: Flow = chatClient.globalStateFlow, + savedStateHandle: SavedStateHandle = SavedStateHandle(), ) { private val channelType = channelCid.cidToTypeAndId().first @@ -247,6 +252,23 @@ public class MessageComposerController( */ public val inputFocusEvents: SharedFlow = _inputFocusEvents.asSharedFlow() + // Insertion-ordered map keyed by attachment key (EXTRA_SOURCE_URI or fallback). + // Tracks picker selections independently of edit-mode attachments, so selections + // survive entering and exiting edit mode. + private val _selectedAttachments = MutableStateFlow(linkedMapOf()) + + // Holds the base attachments from the message being edited. Cleared when edit mode is dismissed. + private val _editModeAttachments = MutableStateFlow>(emptyList()) + + // Holds the message being edited, or null when not in edit mode. + private val _editModeMessage = MutableStateFlow(null) + + // Holds the attachment produced by a completed audio recording, if any. + // Cleared when the composer is reset (e.g. after sending). + private val _recordingAttachment = MutableStateFlow(null) + + private val sessionRepository = ComposerSessionRepository(savedStateHandle) + /** Full message composer state holding all the required information. */ public val state: MutableStateFlow = MutableStateFlow(MessageComposerState()) @@ -388,6 +410,8 @@ public class MessageComposerController( }.launchIn(scope) setupComposerState() + restoreSession() + observeSessionChanges() } /** @@ -459,13 +483,11 @@ public class MessageComposerController( audioRecordingController.recordingState.onEach { recording -> logger.d { "[onRecordingState] recording: $recording" } - state.update { - if (recording is RecordingState.Complete) { - it.copy(recording = recording, attachments = it.attachments + recording.attachment) - } else { - it.copy(recording = recording) - } + if (recording is RecordingState.Complete) { + _recordingAttachment.value = recording.attachment } + state.update { it.copy(recording = recording) } + syncAttachments() }.launchIn(scope) if (config.isDraftMessageEnabled) { @@ -489,6 +511,40 @@ public class MessageComposerController( } } + private fun restoreSession() { + val restoredAttachments = sessionRepository.restoreSelectedAttachments() + if (restoredAttachments.isNotEmpty()) { + addAttachments(restoredAttachments) + } + sessionRepository.restoreEditMode()?.let { editMode -> + restoreEditMode(editMode.message, editMode.attachments) + } + } + + private fun observeSessionChanges() { + combine( + _selectedAttachments, + _editModeMessage, + _editModeAttachments, + ) { selected, editMessage, editAttachments -> + Triple(selected, editMessage, editAttachments) + }.onEach { (selected, editMessage, editAttachments) -> + sessionRepository.save( + selectedAttachments = selected.values.toList(), + editMode = editMessage?.let { ComposerSessionRepository.EditMode(it, editAttachments) }, + ) + }.launchIn(scope) + } + + private fun restoreEditMode(message: Message, attachments: List) { + val fullMessage = channelState.value?.getMessageById(message.id) ?: message + setMessageInputInternal(message.text, MessageInput.Source.Edit) + _editModeMessage.value = fullMessage + _editModeAttachments.value = attachments + messageActions.value += Edit(fullMessage) + syncAttachments() + } + /** * Called when the input changes and the internal state needs to be updated. * @@ -577,8 +633,10 @@ public class MessageComposerController( is Edit -> { setMessageInputInternal(messageAction.message.text, MessageInput.Source.Edit) - state.update { it.copy(attachments = messageAction.message.attachments) } - messageActions.value = messageActions.value + messageAction + _editModeMessage.value = messageAction.message + _editModeAttachments.value = messageAction.message.attachments + messageActions.value += messageAction + syncAttachments() } else -> Unit @@ -591,52 +649,78 @@ public class MessageComposerController( public fun dismissMessageActions() { if (isInEditMode) { setMessageInputInternal("", MessageInput.Source.Default) - state.update { it.copy(attachments = emptyList()) } + _editModeMessage.value = null + _editModeAttachments.value = emptyList() + syncAttachments() } this.messageActions.value = emptySet() } /** - * Updates the selected attachments that are shown within the composer UI. + * Adds [attachments] to the staged list, preserving insertion order. + * + * Each attachment is keyed by its [EXTRA_SOURCE_URI] if present, or by a deterministic + * fallback derived from [Attachment.name] and [Attachment.mimeType]. If a key is already + * present, its value is updated in place without changing its position. + * + * @param attachments The attachments to stage. */ - public fun updateSelectedAttachments(attachments: List) { - state.update { it.copy(attachments = attachments) } - handleValidationErrors() + public fun addAttachments(attachments: List) { + _selectedAttachments.update { current -> + LinkedHashMap(current).also { updated -> + attachments.forEach { attachment -> + updated[attachment.attachmentKey()] = attachment + } + } + } + syncAttachments() } /** - * Stores the selected attachments from the attachment picker. These will be shown in the UI, - * within the composer component. We upload and send these attachments once the user taps on the - * send button. + * Removes [attachment] from the staged list. * - * @param attachments The attachments to store and show in the composer. - */ - public fun addSelectedAttachments(attachments: List) { - logger.d { "[addSelectedAttachments] attachments: $attachments" } - state.update { current -> - val merged = (current.attachments + attachments).distinctBy { - if (it.name != null && it.mimeType?.isNotEmpty() == true) { - it.name - } else { - it - } + * Searches picker selections first (by key), then edit-mode attachments, then the recording + * attachment. Removes from the first list that contains it. + * + * @param attachment The attachment to remove. + */ + public fun removeAttachment(attachment: Attachment) { + val key = attachment.attachmentKey() + when { + _selectedAttachments.value.containsKey(key) -> { + _selectedAttachments.update { LinkedHashMap(it).also { map -> map.remove(key) } } + } + _editModeAttachments.value.any(attachment::equals) -> { + _editModeAttachments.update { it.filterNot(attachment::equals) } + } + _recordingAttachment.value == attachment -> { + _recordingAttachment.value = null } - current.copy(attachments = merged) } - handleValidationErrors() + syncAttachments() } /** - * Removes a selected attachment from the list, when the user taps on the cancel/delete button. - * - * This will update the UI to remove it from the composer component. + * Removes all staged attachments whose URI string key is contained in [uris]. * - * @param attachment The attachment to remove. + * @param uris The URI string keys to remove. */ - public fun removeSelectedAttachment(attachment: Attachment) { - state.update { it.copy(attachments = it.attachments - attachment) } - handleValidationErrors() + public fun removeAttachmentsByUris(uris: Set) { + if (uris.isEmpty()) return + _selectedAttachments.update { current -> LinkedHashMap(current).also { it.keys.removeAll(uris) } } + syncAttachments() + } + + /** + * Removes all staged attachments and updates the composer state. + * Clears picker selections, edit-mode base attachments, and any completed recording attachment. + */ + public fun clearAttachments() { + _editModeAttachments.value = emptyList() + _selectedAttachments.value = linkedMapOf() + _recordingAttachment.value = null + syncAttachments() } /** @@ -662,7 +746,7 @@ public class MessageComposerController( dismissMessageActions() scope.launch { clearDraftMessage(messageMode.value) } messageInput.value = MessageInput() - state.update { it.copy(attachments = emptyList()) } + clearAttachments() clearActiveCommand() validationErrors.value = emptyList() if (!isInThread) { @@ -857,6 +941,17 @@ public class MessageComposerController( } } + private fun syncAttachments() { + state.update { + it.copy( + attachments = _editModeAttachments.value + + _selectedAttachments.value.values.toList() + + listOfNotNull(_recordingAttachment.value), + ) + } + handleValidationErrors() + } + /** * Checks the current input for validation errors. */ @@ -999,8 +1094,9 @@ public class MessageComposerController( public fun sendRecording() { scope.launch { audioRecordingController.completeRecordingSync().onSuccess { recording -> - val attachments = state.value.attachments + recording - sendMessage(buildNewMessage(messageInput.value.text, attachments), callback = {}) + _recordingAttachment.value = recording + syncAttachments() + sendMessage(buildNewMessage(messageInput.value.text, state.value.attachments), callback = {}) } } } @@ -1179,3 +1275,8 @@ public class MessageComposerController( linkPreviews.value = emptyList() } } + +private fun Attachment.sourceUriString(): String? = extraData[EXTRA_SOURCE_URI]?.toString() + +private fun Attachment.attachmentKey(): String = + sourceUriString() ?: "fallback:${name.orEmpty()}:${mimeType.orEmpty()}:$fileSize" diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepositoryTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepositoryTest.kt new file mode 100644 index 00000000000..1aeba089171 --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepositoryTest.kt @@ -0,0 +1,307 @@ +/* + * 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.ui.common.feature.messages.composer + +import androidx.lifecycle.SavedStateHandle +import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.randomAttachment +import io.getstream.chat.android.randomCID +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomString +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class ComposerSessionRepositoryTest { + + @Test + fun `Given nothing saved When restoreSelectedAttachments Then returns empty list`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + + assertTrue(repo.restoreSelectedAttachments().isEmpty()) + } + + @Test + fun `Given picker attachment saved When restoreSelectedAttachments Then all fields are restored`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val attachment = Attachment( + type = randomString(), + name = randomString(), + fileSize = 1024, + mimeType = randomString(), + extraData = mapOf(EXTRA_SOURCE_URI to randomString()), + ) + + repo.save(listOf(attachment), editMode = null) + val restored = repo.restoreSelectedAttachments().first() + + assertEquals(attachment.type, restored.type) + assertEquals(attachment.name, restored.name) + assertEquals(attachment.fileSize, restored.fileSize) + assertEquals(attachment.mimeType, restored.mimeType) + assertEquals(attachment.extraData[EXTRA_SOURCE_URI], restored.extraData[EXTRA_SOURCE_URI]) + } + + @Test + fun `Given multiple picker attachments saved When restoreSelectedAttachments Then all are restored`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val uri1 = randomString() + val uri2 = randomString() + val attachments = listOf( + Attachment(name = randomString(), extraData = mapOf(EXTRA_SOURCE_URI to uri1)), + Attachment(name = randomString(), extraData = mapOf(EXTRA_SOURCE_URI to uri2)), + ) + + repo.save(attachments, editMode = null) + val result = repo.restoreSelectedAttachments() + + assertEquals(2, result.size) + assertEquals(uri1, result[0].extraData[EXTRA_SOURCE_URI]) + assertEquals(uri2, result[1].extraData[EXTRA_SOURCE_URI]) + } + + @Test + fun `Given picker attachment with extra extraData When saved Then EXTRA_SOURCE_URI and other keys are both present after restore`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val uri = randomString() + val customValue = randomString() + val attachment = Attachment( + name = randomString(), + extraData = mapOf(EXTRA_SOURCE_URI to uri, "customKey" to customValue), + ) + + repo.save(listOf(attachment), editMode = null) + val restored = repo.restoreSelectedAttachments().first() + + assertEquals(uri, restored.extraData[EXTRA_SOURCE_URI]) + assertEquals(customValue, restored.extraData["customKey"]) + } + + @Test + fun `Given non-empty state When save called with empty Then restoreSelectedAttachments returns empty`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + repo.save( + listOf(Attachment(extraData = mapOf(EXTRA_SOURCE_URI to randomString()))), + editMode = null, + ) + + repo.save(emptyList(), editMode = null) + + assertTrue(repo.restoreSelectedAttachments().isEmpty()) + } + + @Test + fun `Given nothing saved When restoreEditMode Then returns null`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + + assertNull(repo.restoreEditMode()) + } + + @Test + fun `Given only selected attachments saved When restoreEditMode Then returns null`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + repo.save( + listOf(Attachment(extraData = mapOf(EXTRA_SOURCE_URI to randomString()))), + editMode = null, + ) + + assertNull(repo.restoreEditMode()) + } + + @Test + fun `Given edit mode saved When restoreEditMode Then message fields are restored correctly`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val message = Message( + id = randomString(), + cid = randomCID(), + text = randomString(), + parentId = randomString(), + type = randomString(), + ) + + repo.save(emptyList(), editMode = ComposerSessionRepository.EditMode(message, emptyList())) + val result = repo.restoreEditMode() + + assertEquals(message.id, result?.message?.id) + assertEquals(message.cid, result?.message?.cid) + assertEquals(message.text, result?.message?.text) + assertEquals(message.parentId, result?.message?.parentId) + assertEquals(message.type, result?.message?.type) + } + + @Test + fun `Given message without parentId saved When restoreEditMode Then parentId is null`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val message = randomMessage(parentId = null) + + repo.save(emptyList(), editMode = ComposerSessionRepository.EditMode(message, emptyList())) + + assertNull(repo.restoreEditMode()?.message?.parentId) + } + + @Test + fun `Given edit mode with remote attachment saved When restoreEditMode Then attachment fields are restored`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val attachment = randomAttachment(upload = null) + + repo.save( + emptyList(), + editMode = ComposerSessionRepository.EditMode( + randomMessage(), + listOf(attachment), + ), + ) + val restored = repo.restoreEditMode()!!.attachments.first() + + assertEquals(attachment.type, restored.type) + assertEquals(attachment.name, restored.name) + assertEquals(attachment.fileSize, restored.fileSize) + assertEquals(attachment.mimeType, restored.mimeType) + assertEquals(attachment.assetUrl, restored.assetUrl) + assertEquals(attachment.imageUrl, restored.imageUrl) + assertEquals(attachment.thumbUrl, restored.thumbUrl) + assertEquals(attachment.title, restored.title) + } + + @Test + fun `Given edit mode saved When save called with empty Then restoreEditMode returns null`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + repo.save( + emptyList(), + editMode = ComposerSessionRepository.EditMode(randomMessage(), emptyList()), + ) + + repo.save(emptyList(), editMode = null) + + assertNull(repo.restoreEditMode()) + } + + @Test + fun `Given attachment with string extraData When saved in edit mode Then string value is preserved after restore`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val payload = randomString() + val attachment = randomAttachment( + upload = null, + extraData = mapOf("payload" to payload), + ) + + repo.save( + emptyList(), + editMode = ComposerSessionRepository.EditMode(randomMessage(), listOf(attachment)), + ) + + assertEquals(payload, repo.restoreEditMode()!!.attachments.first().extraData["payload"]) + } + + @Test + fun `Given attachment with all supported primitive types in extraData When saved Then all are preserved after restore`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val attachment = randomAttachment( + upload = null, + extraData = mapOf( + "strKey" to "text", + "boolKey" to true, + "intKey" to 42, + "longKey" to 9_000_000_000L, + "doubleKey" to 3.14, + "floatKey" to 1.5f, + ), + ) + + repo.save( + emptyList(), + editMode = ComposerSessionRepository.EditMode(randomMessage(), listOf(attachment)), + ) + val restored = repo.restoreEditMode()!!.attachments.first().extraData + + assertEquals("text", restored["strKey"]) + assertEquals(true, restored["boolKey"]) + assertEquals(42, restored["intKey"]) + assertEquals(9_000_000_000L, restored["longKey"]) + assertEquals(3.14, (restored["doubleKey"] as Number).toDouble(), 1e-10) + assertEquals(1.5, (restored["floatKey"] as Number).toDouble(), 1e-10) + } + + @Test + fun `Given attachment with non-serializable value in extraData When saved Then non-serializable entry is dropped without error`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val validValue = randomString() + val attachment = randomAttachment( + upload = null, + extraData = mapOf("validKey" to validValue, "objectKey" to object {}), + ) + + repo.save( + emptyList(), + editMode = ComposerSessionRepository.EditMode(randomMessage(), listOf(attachment)), + ) + val restored = repo.restoreEditMode()!!.attachments.first().extraData + + assertEquals(validValue, restored["validKey"]) + assertFalse(restored.containsKey("objectKey")) + } + + @Test + fun `Given edit-mode attachment with only name When saved Then it survives round-trip`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val attachment = Attachment( + name = randomString(), + type = randomString(), + fileSize = 2048, + mimeType = randomString(), + ) + + repo.save( + emptyList(), + editMode = ComposerSessionRepository.EditMode(randomMessage(), listOf(attachment)), + ) + val restored = repo.restoreEditMode()!!.attachments + + assertEquals(1, restored.size) + assertEquals(attachment.name, restored.first().name) + assertEquals(attachment.type, restored.first().type) + assertEquals(attachment.fileSize, restored.first().fileSize) + assertEquals(attachment.mimeType, restored.first().mimeType) + } + + @Test + fun `Given both selected attachments and edit mode saved When restored Then both are returned independently`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val pickerUri = randomString() + val pickerAttachment = Attachment(extraData = mapOf(EXTRA_SOURCE_URI to pickerUri)) + val editAttachment = randomAttachment(upload = null) + val editMessage = randomMessage() + + repo.save( + listOf(pickerAttachment), + editMode = ComposerSessionRepository.EditMode(editMessage, listOf(editAttachment)), + ) + + val selectedAttachments = repo.restoreSelectedAttachments() + val editMode = repo.restoreEditMode() + + assertEquals(1, selectedAttachments.size) + assertEquals(pickerUri, selectedAttachments.first().extraData[EXTRA_SOURCE_URI]) + assertEquals(editMessage.id, editMode?.message?.id) + assertEquals(1, editMode?.attachments?.size) + assertEquals(editAttachment.assetUrl, editMode?.attachments?.first()?.assetUrl) + } +} diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt index 75806fe4d7e..c1ed6cf67c7 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.ui.common.feature.messages.composer +import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.state.GlobalState @@ -43,6 +44,7 @@ import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import io.getstream.chat.android.ui.common.feature.messages.composer.mention.MentionType +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.MessageInput import io.getstream.chat.android.ui.common.state.messages.MessageMode @@ -58,6 +60,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -72,6 +75,7 @@ import org.mockito.kotlin.whenever import java.io.File import java.util.Date +@Suppress("LargeClass") @OptIn(ExperimentalCoroutinesApi::class) internal class MessageComposerControllerTest { @@ -278,7 +282,7 @@ internal class MessageComposerControllerTest { } @Test - fun `Given no attachments When updateSelectedAttachments called Then attachments are set`() = runTest { + fun `Given no attachments When addAttachments called Then attachments are set`() = runTest { // Given val controller = Fixture() .givenAppSettings() @@ -287,13 +291,261 @@ internal class MessageComposerControllerTest { .givenGlobalState() .givenChannelState() .get() - val attachments = listOf(randomAttachment(), randomAttachment()) + val attachments = listOf( + randomAttachment(extraData = mapOf("io.getstream.sourceUri" to "uri:1")), + randomAttachment(extraData = mapOf("io.getstream.sourceUri" to "uri:2")), + ) + + // When + controller.addAttachments(attachments) + + // Then + assertEquals(attachments.size, controller.state.value.attachments.size) + } + + @Test + fun `Given staged attachments When removeAttachment called Then the matching URI is removed`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val a1 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + val a2 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:2")) + controller.addAttachments(listOf(a1, a2)) + + // When + controller.removeAttachment(a1) + + // Then + assertEquals(1, controller.state.value.attachments.size) + assertEquals("uri:2", controller.state.value.attachments.first().extraData[EXTRA_SOURCE_URI]) + } + + @Test + fun `Given staged attachments When removeAttachment called with unknown URI Then nothing changes`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val a1 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + controller.addAttachments(listOf(a1)) + val unknown = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:unknown")) + + // When + controller.removeAttachment(unknown) + + // Then + assertEquals(1, controller.state.value.attachments.size) + } + + @Test + fun `Given staged attachments When removeAttachmentsByUris called Then matching URIs are removed`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val a1 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + val a2 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:2")) + val a3 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:3")) + controller.addAttachments(listOf(a1, a2, a3)) + + // When + controller.removeAttachmentsByUris(setOf("uri:1", "uri:3")) + + // Then + assertEquals(1, controller.state.value.attachments.size) + assertEquals("uri:2", controller.state.value.attachments.first().extraData[EXTRA_SOURCE_URI]) + } + + @Test + fun `Given staged attachments When removeAttachmentsByUris called with empty set Then nothing changes`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val a1 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + controller.addAttachments(listOf(a1)) // When - controller.updateSelectedAttachments(attachments) + controller.removeAttachmentsByUris(emptySet()) // Then - assertEquals(attachments, controller.state.value.attachments) + assertEquals(1, controller.state.value.attachments.size) + } + + @Test + fun `Given staged attachments When clearAttachments called Then attachments list is empty`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + controller.addAttachments( + listOf( + randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")), + randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:2")), + ), + ) + + // When + controller.clearAttachments() + + // Then + assertTrue(controller.state.value.attachments.isEmpty()) + } + + @Test + fun `Given a staged attachment When addAttachments called with same URI Then value is updated and count stays the same`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val original = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + val updated = original.copy(type = "updated_type", extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + controller.addAttachments(listOf(original)) + + // When + controller.addAttachments(listOf(updated)) + + // Then + assertEquals(1, controller.state.value.attachments.size) + assertEquals("updated_type", controller.state.value.attachments.first().type) + } + + @Test + fun `Given attachments added and cleared When state is collected Then it reflects the updated attachments`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val a1 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + val a2 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:2")) + + controller.state.test { + awaitItem() // initial empty emission + + // When + controller.addAttachments(listOf(a1, a2)) + assertEquals(2, awaitItem().attachments.size) + + controller.clearAttachments() + assertTrue(awaitItem().attachments.isEmpty()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given picker attachments staged When entering and dismissing edit mode Then picker selections are preserved`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val pickerAttachment = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + controller.addAttachments(listOf(pickerAttachment)) + + // When + controller.performMessageAction(Edit(randomMessage(cid = CID))) + controller.dismissMessageActions() + + // Then + assertEquals(1, controller.state.value.attachments.size) + assertEquals("uri:1", controller.state.value.attachments.first().extraData[EXTRA_SOURCE_URI]) + } + + @Test + fun `Given edit mode with remote attachment When add picker attachment Then both are staged`() = runTest { + // Given + val remoteAttachment = randomAttachment() + val pickerAttachment = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:picker")) + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + controller.performMessageAction(Edit(randomMessage(cid = CID, attachments = listOf(remoteAttachment)))) + + // When + controller.addAttachments(listOf(pickerAttachment)) + + // Then + assertEquals(2, controller.state.value.attachments.size) + } + + @Test + fun `Given edit mode with remote attachment When remove remote attachment Then it is removed`() = runTest { + // Given + val remoteAttachment = randomAttachment() + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + controller.performMessageAction(Edit(randomMessage(cid = CID, attachments = listOf(remoteAttachment)))) + + // When + controller.removeAttachment(remoteAttachment) + + // Then + assertTrue(controller.state.value.attachments.isEmpty()) + } + + @Test + fun `Given edit mode When dismiss Then edit attachments are cleared and picker selections are preserved`() = runTest { + // Given + val remoteAttachment = randomAttachment() + val pickerAttachment = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:picker")) + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + controller.addAttachments(listOf(pickerAttachment)) + controller.performMessageAction(Edit(randomMessage(cid = CID, attachments = listOf(remoteAttachment)))) + + // When + controller.dismissMessageActions() + + // Then + assertEquals(1, controller.state.value.attachments.size) + assertEquals("uri:picker", controller.state.value.attachments.first().extraData[EXTRA_SOURCE_URI]) } @Test @@ -466,8 +718,6 @@ internal class MessageComposerControllerTest { assertEquals(null, controller.state.value.activeCommand) } - // region sendMessage tests - @Test fun `Given normal mode When sendMessage called Then chatClient sendMessage is invoked with correct message`() = runTest { // Given @@ -519,7 +769,7 @@ internal class MessageComposerControllerTest { val controller = fixture.get() controller.setMessageInput("Hello World") - controller.addSelectedAttachments(listOf(randomAttachment())) + controller.addAttachments(listOf(randomAttachment())) val message = Message(cid = CID, text = "Hello World") val callback: Call.Callback = mock() @@ -782,26 +1032,6 @@ internal class MessageComposerControllerTest { assertEquals("Fixed content", messageCaptor.firstValue.text) } - // endregion - - // region loadNewestMessages tests - // - // Why we verify `inheritScope` instead of `queryChannel`: - // - // The `loadNewestMessages` extension function internally uses `logic.channel().watch()` which - // relies on the StatePlugin's LogicRegistry - a complex internal dependency that cannot be - // easily mocked in unit tests. However, `loadNewestMessages` creates a `CoroutineCall` using - // `chatClient.inheritScope()` as its very first operation: - // - // fun ChatClient.loadNewestMessages(...): Call { - // return CoroutineCall(inheritScope { Job(it) }) { ... } - // } - // - // Therefore, verifying whether `inheritScope` was called serves as a reliable proxy to confirm - // whether `loadNewestMessages` was invoked, without needing to mock the entire StatePlugin - // infrastructure. - // - @Test fun `Given endOfNewerMessages is true When sendMessage called Then loadNewestMessages is not called`() = runTest { // Given @@ -880,7 +1110,163 @@ internal class MessageComposerControllerTest { verify(fixture.chatClient).inheritScope(any()) } - // endregion + @Test + fun `Given URI-less attachments When addAttachments called Then all attachments are staged`() = runTest { + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val a1 = randomAttachment(name = "location.pin", type = "location") + val a2 = randomAttachment(name = "card.custom", type = "custom") + + controller.addAttachments(listOf(a1, a2)) + + assertEquals(2, controller.state.value.attachments.size) + } + + @Test + fun `Given URI-less attachment staged When removeAttachment called Then it is removed by fallback key`() = runTest { + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val attachment = randomAttachment(name = "location.pin", type = "location") + controller.addAttachments(listOf(attachment)) + + controller.removeAttachment(attachment) + + assertTrue(controller.state.value.attachments.isEmpty()) + } + + @Test + fun `Given edit-mode attachment with URI When removeAttachment called Then edit-mode list is checked before picker`() = + runTest { + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val remoteAttachment = randomAttachment() + controller.performMessageAction(Edit(randomMessage(cid = CID, attachments = listOf(remoteAttachment)))) + + controller.removeAttachment(remoteAttachment) + + assertTrue(controller.state.value.attachments.isEmpty()) + } + + @Test + fun `Given picker attachments When syncAttachments called after recording completes Then recording attachment is preserved`() = + runTest { + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val pickerAttachment = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + controller.addAttachments(listOf(pickerAttachment)) + assertEquals(1, controller.state.value.attachments.size) + + // Simulate recording completion by adding another picker attachment + // (which triggers syncAttachments) — the existing attachment should survive + val pickerAttachment2 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:2")) + controller.addAttachments(listOf(pickerAttachment2)) + + assertEquals(2, controller.state.value.attachments.size) + } + + @Test + fun `Given clearAttachments called When recording was completed Then recording attachment is also cleared`() = + runTest { + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val pickerAttachment = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + controller.addAttachments(listOf(pickerAttachment)) + + controller.clearAttachments() + + assertTrue(controller.state.value.attachments.isEmpty()) + } + + @Test + fun `Given persisted edit session When controller restores Then edit action uses full message from channel state`() = + runTest { + val fullMessage = randomMessage( + id = "msg-1", + cid = CID, + text = "original", + createdAt = Date(), + ) + val editedText = "edited text" + val savedStateHandle = SavedStateHandle() + ComposerSessionRepository(savedStateHandle).save( + selectedAttachments = emptyList(), + editMode = ComposerSessionRepository.EditMode( + message = fullMessage.copy(text = editedText), + attachments = emptyList(), + ), + ) + + val controller = Fixture(savedStateHandle = savedStateHandle) + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .givenMessageById(fullMessage) + .get() + advanceUntilIdle() + + assertEquals(editedText, controller.messageInput.value.text) + val action = controller.state.value.action + assertTrue(action is Edit) + assertEquals(fullMessage.id, (action as Edit).message.id) + assertEquals(fullMessage.createdAt, action.message.createdAt) + } + + @Test + fun `Given persisted edit session When channel state has no message Then edit action falls back to stripped message`() = + runTest { + val messageId = "msg-2" + val editedText = "edited text" + val savedStateHandle = SavedStateHandle() + ComposerSessionRepository(savedStateHandle).save( + selectedAttachments = emptyList(), + editMode = ComposerSessionRepository.EditMode( + message = Message(id = messageId, cid = CID, text = editedText), + attachments = emptyList(), + ), + ) + + val controller = Fixture(savedStateHandle = savedStateHandle) + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + advanceUntilIdle() + + assertEquals(editedText, controller.messageInput.value.text) + val action = controller.state.value.action + assertTrue(action is Edit) + assertEquals(messageId, (action as Edit).message.id) + assertNull(action.message.createdAt) + } /** * Custom test implementation of [Mention] for testing purposes. @@ -894,6 +1280,7 @@ internal class MessageComposerControllerTest { private class Fixture( val chatClient: ChatClient = mock(), private val cid: String = CID, + private val savedStateHandle: SavedStateHandle = SavedStateHandle(), ) { private val clientState: ClientState = mock() @@ -969,6 +1356,10 @@ internal class MessageComposerControllerTest { whenever(chatClient.markMessageRead(any(), any(), any())) doReturn Unit.asCall() } + fun givenMessageById(message: Message) = apply { + whenever(channelState.getMessageById(eq(message.id))) doReturn message + } + fun givenEditMessage(message: Message) = apply { whenever(chatClient.editMessage(any(), any(), any())) doReturn message.asCall() } @@ -997,6 +1388,7 @@ internal class MessageComposerControllerTest { fileToUri = mock(), config = config, globalState = MutableStateFlow(globalState), + savedStateHandle = savedStateHandle, ) } } diff --git a/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat.xml b/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat.xml index 1bdaf511577..b7d7a973b7f 100644 --- a/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat.xml +++ b/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat.xml @@ -52,6 +52,7 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/messageListView" + app:streamUiMessageComposerAttachmentsPickerSystemPickerEnabled="false" /> diff --git a/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat_preview.xml b/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat_preview.xml index d2f8ca47593..bae9b8d3367 100644 --- a/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat_preview.xml +++ b/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat_preview.xml @@ -53,6 +53,7 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/messageListView" + app:streamUiMessageComposerAttachmentsPickerSystemPickerEnabled="false" tools:visibility="visible" /> diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index 77b0dc67f0a..ef09e391a2e 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -4511,12 +4511,13 @@ public final class io/getstream/chat/android/ui/viewmodel/mentions/MentionListVi public final class io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel : androidx/lifecycle/ViewModel { public fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController;)V - public final fun addSelectedAttachments (Ljava/util/List;)V + public final fun addAttachments (Ljava/util/List;)V public final fun buildNewMessage ()Lio/getstream/chat/android/models/Message; public final fun buildNewMessage (Ljava/lang/String;)Lio/getstream/chat/android/models/Message; public final fun buildNewMessage (Ljava/lang/String;Ljava/util/List;)Lio/getstream/chat/android/models/Message; public static synthetic fun buildNewMessage$default (Lio/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/chat/android/models/Message; public final fun cancelRecording ()V + public final fun clearAttachments ()V public final fun clearData ()V public final fun completeRecording ()V public final fun createPoll (Lio/getstream/chat/android/models/PollConfig;)V @@ -4535,7 +4536,7 @@ public final class io/getstream/chat/android/ui/viewmodel/messages/MessageCompos public final fun lockRecording ()V public final fun pauseRecording ()V public final fun performMessageAction (Lio/getstream/chat/android/ui/common/state/messages/MessageAction;)V - public final fun removeSelectedAttachment (Lio/getstream/chat/android/models/Attachment;)V + public final fun removeAttachment (Lio/getstream/chat/android/models/Attachment;)V public final fun seekRecordingTo (F)V public final fun selectCommand (Lio/getstream/chat/android/models/Command;)V public final fun selectMention (Lio/getstream/chat/android/models/User;)V @@ -5141,6 +5142,7 @@ public final class io/getstream/chat/android/ui/viewmodel/messages/MessageListVi public fun (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/client/setup/state/ClientState;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/UserLookupHandler;Lkotlin/jvm/functions/Function1;IZIZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFooterVisibility;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/MessagePositionHandler;ZZZZ)V public synthetic fun (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/client/setup/state/ClientState;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/UserLookupHandler;Lkotlin/jvm/functions/Function1;IZIZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFooterVisibility;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/MessagePositionHandler;ZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; + public fun create (Ljava/lang/Class;Landroidx/lifecycle/viewmodel/CreationExtras;)Landroidx/lifecycle/ViewModel; } public final class io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory$Builder { diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt index d343fb304cd..a9e980ed65a 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt @@ -18,11 +18,13 @@ package io.getstream.chat.android.ui.feature.messages.composer.internal import android.content.Context 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.helper.internal.StorageHelper import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData internal fun AttachmentMetaData.toAttachment(context: Context): Attachment { val fileFromUri = StorageHelper().getCachedFileFromUri(context, this) + val extra = uri?.let { mapOf(EXTRA_SOURCE_URI to it.toString()) } ?: emptyMap() return Attachment( upload = fileFromUri, type = type, @@ -30,5 +32,6 @@ internal fun AttachmentMetaData.toAttachment(context: Context): Attachment { fileSize = size.toInt(), mimeType = mimeType, title = title, + extraData = extra, ) } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt index cae9ba5a074..f9715d1e347 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt @@ -144,8 +144,8 @@ public class MessageComposerViewModel( * * @param attachments The attachments to store and show in the composer. */ - public fun addSelectedAttachments(attachments: List): Unit = - messageComposerController.addSelectedAttachments(attachments) + public fun addAttachments(attachments: List): Unit = + messageComposerController.addAttachments(attachments) /** * Removes a selected attachment from the list, when the user taps on the cancel/delete button. @@ -154,8 +154,15 @@ public class MessageComposerViewModel( * * @param attachment The attachment to remove. */ - public fun removeSelectedAttachment(attachment: Attachment): Unit = - messageComposerController.removeSelectedAttachment(attachment) + public fun removeAttachment(attachment: Attachment): Unit = + messageComposerController.removeAttachment(attachment) + + /** + * Removes all staged attachments. + * + * Call this when the attachments are consumed — for example, after a message is sent. + */ + public fun clearAttachments(): Unit = messageComposerController.clearAttachments() public fun createPoll(pollConfig: PollConfig) { messageComposerController.createPoll(pollConfig) diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModelBinding.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModelBinding.kt index 05a7e5ef780..f13ac901a33 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModelBinding.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModelBinding.kt @@ -240,19 +240,11 @@ private infix fun (() -> Unit).and(that: (() -> Unit)?): () -> Unit = when (that } internal object MessageComposerViewModelDefaults { - val MessageComposerViewModel.sendMessageButtonClickListener: (Message) -> Unit - get() = { - sendMessage(it) - } + val MessageComposerViewModel.sendMessageButtonClickListener: (Message) -> Unit get() = ::sendMessage val MessageComposerViewModel.textInputChangeListener: (String) -> Unit get() = { setMessageInput(it) } - val MessageComposerViewModel.attachmentSelectionListener: (List) -> Unit - get() = { - addSelectedAttachments( - it, - ) - } + val MessageComposerViewModel.attachmentSelectionListener: (List) -> Unit get() = ::addAttachments val MessageComposerViewModel.pollSubmissionListener: (PollConfig) -> Unit get() = { createPoll(it) } - val MessageComposerViewModel.attachmentRemovalListener: (Attachment) -> Unit get() = { removeSelectedAttachment(it) } + val MessageComposerViewModel.attachmentRemovalListener: (Attachment) -> Unit get() = ::removeAttachment val MessageComposerViewModel.mentionSelectionListener: (User) -> Unit get() = { selectMention(it) } val MessageComposerViewModel.commandSelectionListener: (Command) -> Unit get() = { selectCommand(it) } val MessageComposerViewModel.alsoSendToChannelSelectionListener: (Boolean) -> Unit get() = { setAlsoSendToChannel(it) } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory.kt index 6d791780e9f..a2b4224567d 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory.kt @@ -18,8 +18,11 @@ package io.getstream.chat.android.ui.viewmodel.messages import android.content.Context import androidx.core.net.toUri +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.state.watchChannelAsState import io.getstream.chat.android.client.channel.state.ChannelState @@ -107,10 +110,16 @@ public class MessageListViewModelFactory @JvmOverloads constructor( ) } - private val factories: Map, () -> ViewModel> = mapOf( - MessageListHeaderViewModel::class.java to { MessageListHeaderViewModel(cid, messageId = messageId) }, - MessageListViewModel::class.java to { - MessageListViewModel( + override fun create(modelClass: Class, extras: CreationExtras): T = + createInternal(modelClass, savedStateHandle = extras.createSavedStateHandle()) + + override fun create(modelClass: Class): T = + createInternal(modelClass, savedStateHandle = SavedStateHandle()) + + private fun createInternal(modelClass: Class, savedStateHandle: SavedStateHandle): T { + val viewModel: ViewModel = when (modelClass) { + MessageListHeaderViewModel::class.java -> MessageListHeaderViewModel(cid, messageId = messageId) + MessageListViewModel::class.java -> MessageListViewModel( messageListController = MessageListController( cid = cid, clipboardHandler = {}, @@ -133,9 +142,7 @@ public class MessageListViewModelFactory @JvmOverloads constructor( ), chatClient = chatClient, ) - }, - MessageComposerViewModel::class.java to { - MessageComposerViewModel( + MessageComposerViewModel::class.java -> MessageComposerViewModel( MessageComposerController( channelCid = cid, chatClient = chatClient, @@ -147,14 +154,16 @@ public class MessageListViewModelFactory @JvmOverloads constructor( maxAttachmentCount = maxAttachmentCount, isDraftMessageEnabled = isComposerDraftMessagesEnabled, ), + savedStateHandle = savedStateHandle, ), ) - }, - ) - - override fun create(modelClass: Class): T { - val viewModel: ViewModel = factories[modelClass]?.invoke() - ?: throw IllegalArgumentException("MessageListViewModelFactory can only create instances of the following classes: ${factories.keys.joinToString { it.simpleName }}") + else -> throw IllegalArgumentException( + "MessageListViewModelFactory can only create instances of the following classes: " + + "${MessageListHeaderViewModel::class.java.simpleName}, " + + "${MessageListViewModel::class.java.simpleName}, or " + + "${MessageComposerViewModel::class.java.simpleName}.", + ) + } @Suppress("UNCHECKED_CAST") return viewModel as T diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt index 40bcd254c45..b398202e887 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt @@ -125,10 +125,10 @@ internal class MessageComposerViewModelTest { .givenSendMessage() .get() - viewModel.addSelectedAttachments( + viewModel.addAttachments( listOf( - Attachment(imageUrl = "url1"), - Attachment(imageUrl = "url2"), + Attachment(imageUrl = "url1", extraData = mapOf("io.getstream.sourceUri" to "uri:1")), + Attachment(imageUrl = "url2", extraData = mapOf("io.getstream.sourceUri" to "uri:2")), ), ) val state = viewModel.messageComposerState.value @@ -160,14 +160,14 @@ internal class MessageComposerViewModelTest { .givenChannelState() .get() - viewModel.addSelectedAttachments( + viewModel.addAttachments( listOf( - Attachment(imageUrl = "url1"), - Attachment(imageUrl = "url2"), + Attachment(imageUrl = "url1", extraData = mapOf("io.getstream.sourceUri" to "uri:1")), + Attachment(imageUrl = "url2", extraData = mapOf("io.getstream.sourceUri" to "uri:2")), ), ) - viewModel.removeSelectedAttachment( - Attachment(imageUrl = "url1"), + viewModel.removeAttachment( + Attachment(imageUrl = "url1", extraData = mapOf("io.getstream.sourceUri" to "uri:1")), ) viewModel.messageComposerState.value.attachments.size `should be equal to` 1 diff --git a/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/compose/customattachments/MessagesActivity.kt b/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/compose/customattachments/MessagesActivity.kt index 126194751d7..584ce39755c 100644 --- a/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/compose/customattachments/MessagesActivity.kt +++ b/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/compose/customattachments/MessagesActivity.kt @@ -134,7 +134,7 @@ class MessagesActivity : AppCompatActivity() { ) // 3 - composerViewModel.addSelectedAttachments(listOf(attachment)) + composerViewModel.addAttachments(listOf(attachment)) }, ) }, diff --git a/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/uicomponents/customattachments/MessagesActivity.kt b/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/uicomponents/customattachments/MessagesActivity.kt index a5004b3d0dc..a2ccd5b9410 100644 --- a/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/uicomponents/customattachments/MessagesActivity.kt +++ b/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/uicomponents/customattachments/MessagesActivity.kt @@ -125,7 +125,7 @@ class MessagesActivity : AppCompatActivity() { type = "date", extraData = mutableMapOf("payload" to payload), ) - messageComposerViewModel.addSelectedAttachments(listOf(attachment)) + messageComposerViewModel.addAttachments(listOf(attachment)) } // Show the date picker dialog at the click of the calendar button