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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ public data class ComposerOptions(
* @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 parentMessageId The ID of the parent [Message] to open the screen directly in a thread: the message list is
* scoped to that thread and the composer starts in thread mode. Pass `null` to open the channel.
* @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.
Expand Down Expand Up @@ -184,6 +184,7 @@ public class ChannelViewModelFactory(
activeCommandEnabled = true,
),
savedStateHandle = savedStateHandle ?: SavedStateHandle(),
initialParentMessageId = parentMessageId,
),
storageHelper = storageHelper,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ import java.util.regex.Pattern
* @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).
* @param initialParentMessageId The ID of the parent [Message] to open the composer directly in a
* thread, starting it in thread mode. `null` opens the composer in the channel.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@InternalStreamChatApi
Expand All @@ -130,6 +132,7 @@ public class MessageComposerController(
private val config: Config = Config(),
private val globalState: Flow<GlobalState> = chatClient.globalStateFlow,
savedStateHandle: SavedStateHandle = SavedStateHandle(),
initialParentMessageId: String? = null,
) {

private val channelType = channelCid.cidToTypeAndId().first
Expand Down Expand Up @@ -266,7 +269,15 @@ public class MessageComposerController(

private val sessionRepository = ComposerSessionRepository(savedStateHandle)

private val _state = MutableStateFlow(MessageComposerState())
// Render the final thread state from the first frame, so the composer does not change during the
// opening transition and its initial draft fetch targets the thread, not the channel.
private val _state = MutableStateFlow(
MessageComposerState(
messageMode = initialParentMessageId
?.let { MessageMode.MessageThread(parentMessage = Message(id = it)) }
?: MessageMode.Normal,
),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Full message composer state holding all the required information. */
public val state: StateFlow<MessageComposerState> = _state.asStateFlow()
Expand Down Expand Up @@ -600,9 +611,11 @@ public class MessageComposerController(
*/
public fun setMessageMode(messageMode: MessageMode) {
val previousMode = _state.value.messageMode
_state.update { it.copy(messageMode = messageMode) }
// The mode is refreshed above so the fully loaded thread parent replaces the id-only stub,
// but an unchanged logical mode does not need its draft saved and reloaded.
if (isSameMessageMode(previousMode, messageMode)) return
scope.launch(start = CoroutineStart.UNDISPATCHED) {
_state.update { it.copy(messageMode = messageMode) }
saveDraftMessage(previousMode)
fetchDraftMessage(messageMode)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2039,6 +2039,54 @@ internal class MessageComposerControllerTest {
assertEquals(Reply(repliedInThread), controller.state.value.action)
}

@Test
fun `Given a parentMessageId When created Then it starts in that thread and loads the thread draft`() = runTest {
// Opening directly for a thread must show the final thread state from the first frame (no
// jump during the opening transition) and load the thread draft, not the channel one.
val parentMessage = randomMessage(cid = CID)
val threadDraft = DraftMessage(cid = CID, parentId = parentMessage.id, text = "thread draft")
val controller = Fixture()
.givenAppSettings()
.givenAudioPlayer(mock())
.givenClientState(randomUser())
.givenGlobalState(
channelDrafts = mapOf(CID to DraftMessage(cid = CID, text = "channel draft")),
threadDrafts = mapOf(parentMessage.id to threadDraft),
)
.givenChannelState()
.givenDraftMessageStubs()
.get(parentMessageId = parentMessage.id)
advanceUntilIdle()

val mode = controller.state.value.messageMode
assertTrue(mode is MessageMode.MessageThread)
assertEquals(parentMessage.id, (mode as MessageMode.MessageThread).parentMessage.id)
assertEquals("thread draft", controller.state.value.inputValue)
}

@Test
fun `Given a stubbed thread When the loaded parent arrives Then the full message replaces the stub`() = runTest {
// The composer starts with an id-only parent stub; when the list provides the fully loaded
// parent it must replace the stub so state exposes the real message, not the placeholder.
val parentMessage = randomMessage(cid = CID, text = "parent text")
val controller = Fixture()
.givenAppSettings()
.givenAudioPlayer(mock())
.givenClientState(randomUser())
.givenGlobalState()
.givenChannelState()
.givenDraftMessageStubs()
.get(parentMessageId = parentMessage.id)
advanceUntilIdle()

controller.setMessageMode(MessageMode.MessageThread(parentMessage))
advanceUntilIdle()

val mode = controller.state.value.messageMode
assertTrue(mode is MessageMode.MessageThread)
assertEquals(parentMessage, (mode as MessageMode.MessageThread).parentMessage)
}

@Test
fun `Given activeCommandEnabled false When draft is saved Then command and args are not persisted`() = runTest {
// Given — legacy mode with a command active in state (legacy keeps slash inside text).
Expand Down Expand Up @@ -2959,7 +3007,7 @@ internal class MessageComposerControllerTest {
whenever(globalState.threadDraftMessages) doReturn MutableStateFlow(threadDrafts)
}

fun get(): MessageComposerController {
fun get(parentMessageId: String? = null): MessageComposerController {
whenever(chatClient.inheritScope(any())) doReturn inheritedScope
whenever(chatClient.searchUserGroups(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) doReturn
emptyList<UserGroup>().asCall()
Expand All @@ -2976,6 +3024,7 @@ internal class MessageComposerControllerTest {
config = config,
globalState = MutableStateFlow(globalState),
savedStateHandle = savedStateHandle,
initialParentMessageId = parentMessageId,
)
}
}
Expand Down
Loading