Decouple add message dialog from old view model.

This commit is contained in:
Alex Hart
2026-06-16 09:56:12 -04:00
committed by Greyson Parrelli
parent 539276673a
commit 276d71d365
8 changed files with 186 additions and 81 deletions
@@ -0,0 +1,18 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.keyboard.emoji
import android.view.KeyEvent
/**
* Mapping of [EmojiKeyboardCallback] methods into a sealed event class
*/
sealed interface EmojiKeyboardEvent {
object OpenEmojiSearch : EmojiKeyboardEvent
object CloseEmojiSearch : EmojiKeyboardEvent
data class EmojiInsert(val emoji: String?) : EmojiKeyboardEvent
data class EmojiKeyEvent(val keyEvent: KeyEvent?) : EmojiKeyboardEvent
}
@@ -0,0 +1,28 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.keyboard.emoji
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
/**
* Glue ViewModel that allows a component to dispatch emoji events to subcomponents.
*/
class EmojiKeyboardEventViewModel : ViewModel() {
private val eventChannel = Channel<EmojiKeyboardEvent>(Channel.BUFFERED)
val events: Flow<EmojiKeyboardEvent> = eventChannel.receiveAsFlow()
fun onEvent(event: EmojiKeyboardEvent) {
viewModelScope.launch {
eventChannel.send(event)
}
}
}
@@ -1,7 +1,5 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.view.KeyEvent
sealed class HudCommand {
object StartDraw : HudCommand()
object StartCropAndRotate : HudCommand()
@@ -11,9 +9,4 @@ sealed class HudCommand {
object GoToCapture : HudCommand()
object ResumeEntryTransition : HudCommand()
object OpenEmojiSearch : HudCommand()
object CloseEmojiSearch : HudCommand()
data class EmojiInsert(val emoji: String?) : HudCommand()
data class EmojiKeyEvent(val keyEvent: KeyEvent?) : HudCommand()
}
@@ -37,6 +37,8 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardEvent
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardEventViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
@@ -52,6 +54,7 @@ import org.thoughtcrime.securesms.util.FullscreenHelper
import org.thoughtcrime.securesms.util.WindowUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
import kotlin.getValue
import org.signal.core.ui.R as CoreUiR
class MediaSelectionActivity :
@@ -76,6 +79,8 @@ class MediaSelectionActivity :
}
)
private val addMessageCommandViewModel: EmojiKeyboardEventViewModel by viewModels()
private val destination: MediaSelectionDestination
get() = MediaSelectionDestination.fromBundle(requireNotNull(intent.getBundleExtra(DESTINATION)))
@@ -350,19 +355,19 @@ class MediaSelectionActivity :
private fun isCameraFirst(): Boolean = intent.getIntExtra(START_ACTION, -1) == R.id.action_directly_to_mediaCaptureFragment
override fun openEmojiSearch() {
viewModel.sendCommand(HudCommand.OpenEmojiSearch)
addMessageCommandViewModel.onEvent(EmojiKeyboardEvent.OpenEmojiSearch)
}
override fun onEmojiSelected(emoji: String?) {
viewModel.sendCommand(HudCommand.EmojiInsert(emoji))
addMessageCommandViewModel.onEvent(EmojiKeyboardEvent.EmojiInsert(emoji))
}
override fun onKeyEvent(keyEvent: KeyEvent?) {
viewModel.sendCommand(HudCommand.EmojiKeyEvent(keyEvent))
addMessageCommandViewModel.onEvent(EmojiKeyboardEvent.EmojiKeyEvent(keyEvent))
}
override fun closeEmojiSearch() {
viewModel.sendCommand(HudCommand.CloseEmojiSearch)
addMessageCommandViewModel.onEvent(EmojiKeyboardEvent.CloseEmojiSearch)
}
private inner class OnBackPressed : OnBackPressedCallback(true) {
@@ -8,8 +8,6 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.common.io.ByteStreams
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
@@ -17,13 +15,11 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.models.media.Media
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.Util
import org.signal.core.util.getParcelableArrayListCompat
import org.signal.core.util.getParcelableCompat
@@ -33,7 +29,6 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.conversation.MessageStyler
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.v2.review.AddMessageCharacterCount
import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.SentMediaQuality
@@ -74,8 +69,6 @@ class MediaSelectionViewModel(
)
)
private val addAMessageUpdatePublisher = BehaviorProcessor.create<CharSequence>()
val isContactSelectionRequired = destination == MediaSelectionDestination.ChooseAfterMediaSelection
val state: LiveData<MediaSelectionState> = store.stateLiveData
@@ -87,22 +80,6 @@ class MediaSelectionViewModel(
private val disposables = CompositeDisposable()
fun watchAddAMessageCount(): Flowable<AddMessageCharacterCount> {
return addAMessageUpdatePublisher
.onBackpressureLatest()
.map {
val iterator = BreakIteratorCompat.getInstance()
iterator.setText(it)
AddMessageCharacterCount(iterator.countBreaks())
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun updateAddAMessageCount(input: CharSequence?) {
addAMessageUpdatePublisher.onNext(input ?: "")
}
private val isMeteredDisposable: Disposable = repository.isMetered.subscribe { metered ->
store.update {
it.copy(
@@ -11,14 +11,21 @@ import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.signal.core.ui.view.Stub
import org.signal.core.util.ByteLimitInputFilter
import org.signal.core.util.EditTextUtil
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.warning.guardAgainstRecoveryKeyPaste
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
@@ -36,32 +43,29 @@ import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewMod
import org.thoughtcrime.securesms.databinding.V2MediaAddMessageDialogFragmentBinding
import org.thoughtcrime.securesms.keyboard.KeyboardPage
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionState
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardEvent
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardEventViewModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.util.visible
class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_add_message_dialog_fragment) {
private val viewModel: MediaSelectionViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private val viewModel: AddMessageViewModel by viewModel {
AddMessageViewModel(initialMessage = requireArguments().getCharSequence(ARG_INITIAL_TEXT, null))
}
private val keyboardPagerViewModel: KeyboardPagerViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private val commandViewModel: EmojiKeyboardEventViewModel by activityViewModels()
private val keyboardPagerViewModel: KeyboardPagerViewModel by activityViewModels()
private lateinit var mentionsViewModel: MentionsPickerViewModel
private val inlineQueryViewModel: InlineQueryViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private val inlineQueryViewModel: InlineQueryViewModel by activityViewModels()
private val binding by ViewBinderDelegate(V2MediaAddMessageDialogFragmentBinding::bind, onBindingWillBeDestroyed = { binding ->
binding.content.addAMessageInput.setInlineQueryChangedListener(null)
@@ -86,6 +90,11 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (viewModel.isViewOnce) {
dismissAllowingStateLoss()
return
}
emojiDrawerStub = Stub(binding.content.emojiDrawerStub)
if (Stories.isFeatureEnabled()) {
@@ -97,7 +106,7 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a
})
binding.content.addAMessageInput.setText(requireArguments().getCharSequence(ARG_INITIAL_TEXT))
binding.content.addAMessageInput.addTextChangedListener { viewModel.setMessage(it) }
binding.content.addAMessageInput.addTextChangedListener { viewModel.message = it }
binding.content.addAMessageInput.filters += ByteLimitInputFilter(MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES)
binding.content.addAMessageInput.guardAgainstRecoveryKeyPaste(
host = this,
@@ -116,43 +125,36 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a
binding.hud.setOnClickListener { dismissAllowingStateLoss() }
binding.content.viewOnceToggle.setOnClickListener {
viewModel.message = null
viewModel.isViewOnce = true
dismissAllowingStateLoss()
viewModel.incrementViewOnceState()
}
val confirm: View = view.findViewById(R.id.confirm_button)
confirm.setOnClickListener { dismissAllowingStateLoss() }
disposables += viewModel.watchAddAMessageCount().subscribe { count ->
binding.content.addAMessageLimit.visible = count.shouldDisplayCount()
binding.content.addAMessageLimit.text = count.getRemaining().toString()
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
viewModel.watchAddAMessageCount().collect { count ->
binding.content.addAMessageLimit.visible = count.shouldDisplayCount()
binding.content.addAMessageLimit.text = count.getRemaining().toString()
}
}
disposables.add(
viewModel.hudCommands.observeOn(AndroidSchedulers.mainThread()).subscribe {
when (it) {
HudCommand.OpenEmojiSearch -> openEmojiSearch()
HudCommand.CloseEmojiSearch -> closeEmojiSearch()
is HudCommand.EmojiKeyEvent -> onKeyEvent(it.keyEvent)
is HudCommand.EmojiInsert -> onEmojiSelected(it.emoji)
else -> Unit
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(state = Lifecycle.State.RESUMED) {
commandViewModel.events.collect {
when (it) {
EmojiKeyboardEvent.CloseEmojiSearch -> closeEmojiSearch()
is EmojiKeyboardEvent.EmojiInsert -> onEmojiSelected(it.emoji)
is EmojiKeyboardEvent.EmojiKeyEvent -> onKeyEvent(it.keyEvent)
EmojiKeyboardEvent.OpenEmojiSearch -> openEmojiSearch()
}
}
}
)
viewModel.state.observe(viewLifecycleOwner) { state ->
val newChild = if (state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE) 1 else 0
if (binding.content.viewOnceToggle.displayedChild != newChild) {
binding.content.viewOnceToggle.displayedChild = newChild
}
if (state.viewOnceToggleState == MediaSelectionState.ViewOnceToggleState.ONCE) {
binding.content.addAMessageInput.text = null
dismiss()
}
binding.content.viewOnceToggle.visible = state.selectedMedia.size == 1 && !state.isStory && !MediaUtil.isDocumentType(state.focusedMedia?.contentType)
}
binding.content.viewOnceToggle.visible = requireArguments().getBoolean(ARG_VIEW_ONCE_AVAILABLE, false)
initializeMentions()
}
@@ -165,9 +167,14 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (isResumed) {
viewModel.setMessage(binding.content.addAMessageInput.text)
}
setFragmentResult(
REQUEST_KEY,
Bundle().apply {
putCharSequence(RESULT_MESSAGE, viewModel.message)
putBoolean(RESULT_INCREMENT_VIEW_ONCE_STATE, viewModel.isViewOnce)
}
)
}
override fun onKeyboardHidden() {
@@ -213,10 +220,12 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a
}
inlineQueryViewModel.onQueryChange(inlineQuery)
}
is InlineQuery.Emoji -> {
inlineQueryViewModel.onQueryChange(inlineQuery)
mentionsViewModel.onQueryChange(null)
}
is NoQuery -> {
mentionsViewModel.onQueryChange(null)
inlineQueryViewModel.onQueryChange(inlineQuery)
@@ -234,7 +243,7 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a
.observeOn(AndroidSchedulers.mainThread())
.subscribe { r -> binding.content.addAMessageInput.replaceText(r) }
val recipientId: RecipientId = viewModel.destination.getRecipientSearchKey()?.recipientId ?: return
val recipientId: RecipientId = requireArguments().getParcelableCompat(ARG_DESTINATION, RecipientId::class.java) ?: return
Recipient.live(recipientId).observe(viewLifecycleOwner) { recipient ->
this.recipient = recipient
@@ -322,12 +331,20 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a
private const val ARG_INITIAL_TEXT = "arg.initial.text"
private const val ARG_INITIAL_EMOJI_TOGGLE = "arg.initial.emojiToggle"
private const val ARG_VIEW_ONCE_AVAILABLE = "arg.viewOnceAvailable"
private const val ARG_DESTINATION = "arg.destination"
fun show(fragmentManager: FragmentManager, initialText: CharSequence?, startWithEmojiKeyboard: Boolean) {
const val REQUEST_KEY = "AddMessageViewModel__RequestKey"
const val RESULT_INCREMENT_VIEW_ONCE_STATE = "AddMessageViewModel_IncrementViewOnceState"
const val RESULT_MESSAGE = "AddMessageViewModel__Message"
fun show(fragmentManager: FragmentManager, initialText: CharSequence?, startWithEmojiKeyboard: Boolean, isViewOnceAvailable: Boolean, destination: RecipientId?) {
AddMessageDialogFragment().apply {
arguments = Bundle().apply {
putCharSequence(ARG_INITIAL_TEXT, initialText)
putBoolean(ARG_INITIAL_EMOJI_TOGGLE, startWithEmojiKeyboard)
putBoolean(ARG_VIEW_ONCE_AVAILABLE, isViewOnceAvailable)
putParcelable(ARG_DESTINATION, destination)
}
}.show(fragmentManager, TAG)
}
@@ -0,0 +1,42 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v2.review
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import org.signal.core.util.BreakIteratorCompat
class AddMessageViewModel(initialMessage: CharSequence?) : ViewModel() {
var message: CharSequence? = initialMessage
var isViewOnce: Boolean = false
private val addAMessageUpdatePublisher = Channel<CharSequence>(Channel.CONFLATED)
fun watchAddAMessageCount(): Flow<AddMessageCharacterCount> {
return addAMessageUpdatePublisher
.receiveAsFlow()
.map {
val iterator = BreakIteratorCompat.getInstance()
iterator.setText(it)
AddMessageCharacterCount(iterator.countBreaks())
}
.flowOn(Dispatchers.IO)
}
fun updateAddAMessageCount(input: CharSequence?) {
viewModelScope.launch {
addAMessageUpdatePublisher.send(input ?: "")
}
}
}
@@ -125,6 +125,15 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
disposables.bindTo(viewLifecycleOwner)
parentFragmentManager.setFragmentResultListener(AddMessageDialogFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
if (bundle.getBoolean(AddMessageDialogFragment.RESULT_INCREMENT_VIEW_ONCE_STATE)) {
sharedViewModel.setMessage(null)
sharedViewModel.incrementViewOnceState()
} else {
sharedViewModel.setMessage(bundle.getCharSequence(AddMessageDialogFragment.RESULT_MESSAGE, null))
}
}
callback = requireListener()
drawToolButton = view.findViewById(R.id.draw_tool)
@@ -300,11 +309,27 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
}
emojiButton.setOnClickListener {
AddMessageDialogFragment.show(parentFragmentManager, sharedViewModel.state.value?.message, true)
sharedViewModel.state.value?.let { state ->
AddMessageDialogFragment.show(
parentFragmentManager,
state.message,
true,
state.selectedMedia.size == 1 && !state.isStory && !MediaUtil.isDocumentType(state.focusedMedia?.contentType),
sharedViewModel.destination.getRecipientSearchKey()?.recipientId
)
}
}
addMessageButton.setOnClickListener {
AddMessageDialogFragment.show(parentFragmentManager, sharedViewModel.state.value?.message, false)
sharedViewModel.state.value?.let { state ->
AddMessageDialogFragment.show(
parentFragmentManager,
state.message,
false,
state.selectedMedia.size == 1 && !state.isStory && !MediaUtil.isDocumentType(state.focusedMedia?.contentType),
sharedViewModel.destination.getRecipientSearchKey()?.recipientId
)
}
}
if (sharedViewModel.isReply) {