Utilize events instead of direct calls in main nav view model.

This commit is contained in:
Alex Hart
2026-09-02 16:11:28 -03:00
parent 20cfaa6588
commit cac953c724
16 changed files with 286 additions and 265 deletions
@@ -82,6 +82,7 @@ import kotlinx.coroutines.withContext
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.NavigationType
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.split.ListDetailEvents
import org.signal.core.ui.compose.split.ListDetailNavDisplay
import org.signal.core.ui.compose.split.ListDetailPaneLayout
import org.signal.core.ui.compose.split.ListDetailPaneMetrics
@@ -145,8 +146,9 @@ import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.main.MainListRoute
import org.thoughtcrime.securesms.main.MainMegaphoneState
import org.thoughtcrime.securesms.main.MainNavigationBar
import org.thoughtcrime.securesms.main.MainNavigationEventSink
import org.thoughtcrime.securesms.main.MainNavigationEvents
import org.thoughtcrime.securesms.main.MainNavigationRail
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainSnackbar
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
@@ -188,7 +190,7 @@ class MainActivity :
MainNavigator.NavigatorProvider,
Material3OnScrollHelperBinder,
ConversationListFragment.Callback,
MainNavigationRouter,
MainNavigationEventSink,
CallLogFragment.Callback,
GooglePayComponent {
@@ -263,7 +265,7 @@ class MainActivity :
private val mainBottomChromeCallback = BottomChromeCallback()
private val megaphoneActionController = MainMegaphoneActionController()
private val mainNavigationCallback = MainNavigationCallback()
private val mainNavigationCallback: (MainListRoute) -> Unit = { mainNavigationViewModel.onEvent(MainNavigationEvents.GoToTab(it)) }
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
@@ -287,7 +289,7 @@ class MainActivity :
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
override fun onForeground() {
mainNavigationViewModel.getNextMegaphone()
mainNavigationViewModel.onEvent(MainNavigationEvents.RequestNextMegaphone)
}
})
@@ -397,7 +399,7 @@ class MainActivity :
val isBackHandlerEnabled = mainToolbarState.destination != MainListRoute.Chats && !isActionModeActive && !isSearchModeActive
BackHandler(enabled = isBackHandlerEnabled) {
mainNavigationViewModel.goTo(MainListRoute.Chats)
mainNavigationViewModel.onEvent(MainNavigationEvents.GoToList(MainListRoute.Chats))
}
BackHandler(enabled = isActionModeActive) {
@@ -491,8 +493,8 @@ class MainActivity :
entries = tabEntries,
isSplitPane = isSplitPane,
paneAnchor = paneAnchor,
onBack = { mainNavigationViewModel.popCurrentDetailLocation() },
onExitDetail = { mainNavigationViewModel.exitDetailLocation() },
onBack = { mainNavigationViewModel.onEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back)) },
onExitDetail = { mainNavigationViewModel.onEvent(MainNavigationEvents.ExitDetail) },
layout = paneLayout,
listPaneChrome = listPaneChrome,
emptyDetailContent = emptyDetailContent,
@@ -540,7 +542,7 @@ class MainActivity :
return rememberListDetailPaneLayout(
paneAnchor = paneAnchor,
maxWidth = maxWidth,
onAnchorSelected = { mainNavigationViewModel.onPaneAnchorSelected(it) },
onAnchorSelected = { mainNavigationViewModel.onEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.AnchorSelected(it))) },
metrics = contentLayoutData,
// Searching hides the rail, leaving nothing of the list pane behind once the detail fills the window.
collapsedListWidth = when {
@@ -690,29 +692,20 @@ class MainActivity :
val extras = intent.extras ?: return
if (extras.getBoolean(KEY_EXIT_DETAIL, false)) {
mainNavigationViewModel.exitDetailLocation()
mainNavigationViewModel.onEvent(MainNavigationEvents.ExitDetail)
return
}
val detailLocation = extras.getParcelableCompat(KEY_DETAIL_LOCATION, MainDetailRoute::class.java)
if (detailLocation != null) {
goTo(detailLocation)
mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(detailLocation))
return
}
val startingTab = extras.getSerializableCompat(KEY_STARTING_TAB, MainListRoute::class.java)
val startingTab = extras.getSerializableCompat(KEY_STARTING_TAB, MainListRoute::class.java) ?: return
when (startingTab) {
MainListRoute.Chats -> mainNavigationViewModel.onChatsSelected()
MainListRoute.Archive -> mainNavigationViewModel.onArchiveSelected()
MainListRoute.Calls -> mainNavigationViewModel.onCallsSelected()
MainListRoute.Stories -> {
if (Stories.isFeatureEnabled()) {
mainNavigationViewModel.onStoriesSelected()
}
}
null -> Unit
if (startingTab != MainListRoute.Stories || Stories.isFeatureEnabled()) {
mainNavigationViewModel.onEvent(MainNavigationEvents.GoToTab(startingTab))
}
}
@@ -749,7 +742,7 @@ class MainActivity :
}
vitalsViewModel.checkSlowNotificationHeuristics()
mainNavigationViewModel.refreshNavigationBarState()
mainNavigationViewModel.onEvent(MainNavigationEvents.RefreshNavigationBar)
CallQuality.consumeQualityRequest()?.let {
CallQualityBottomSheetFragment.create(it).show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
@@ -775,7 +768,7 @@ class MainActivity :
if (resultCode == RESULT_OK && requestCode == CreateSvrPinActivity.REQUEST_NEW_PIN) {
mainNavigationViewModel.snackbarRegistry.emit(SnackbarState(message = getString(R.string.ConfirmKbsPinFragment__pin_created), hostKey = MainSnackbarHostKey.MainChrome))
mainNavigationViewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL)
mainNavigationViewModel.onEvent(MainNavigationEvents.MegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL))
}
if (resultCode == RESULT_OK && requestCode == UsernameEditFragment.REQUEST_CODE) {
@@ -796,7 +789,7 @@ class MainActivity :
hostKey = MainSnackbarHostKey.MainChrome
)
)
mainNavigationViewModel.onMegaphoneSnoozed(Megaphones.Event.VERIFY_BACKUP_KEY)
mainNavigationViewModel.onEvent(MainNavigationEvents.MegaphoneSnoozed(Megaphones.Event.VERIFY_BACKUP_KEY))
}
}
@@ -896,8 +889,8 @@ class MainActivity :
return
}
mainNavigationViewModel.goTo(MainListRoute.Chats)
mainNavigationViewModel.goTo(MainDetailRoute.Conversation(ConversationIntents.readArgsFromBundle(extras)))
mainNavigationViewModel.onEvent(MainNavigationEvents.GoToList(MainListRoute.Chats))
mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Conversation(ConversationIntents.readArgsFromBundle(extras))))
intent.action = null
setIntent(intent)
}
@@ -1033,7 +1026,7 @@ class MainActivity :
}
override fun onOpenArchiveClick() {
mainNavigationViewModel.onArchiveSelected()
mainNavigationViewModel.onEvent(MainNavigationEvents.GoToTab(MainListRoute.Archive))
}
override fun onStarredMessagesClick() {
@@ -1069,11 +1062,11 @@ class MainActivity :
}
override fun onStoryPrivacyClick() {
mainNavigationViewModel.goTo(MainDetailRoute.Stories.PrivacySettings)
mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Stories.PrivacySettings))
}
override fun onStoryArchiveClick() {
mainNavigationViewModel.goTo(MainDetailRoute.Stories.Archive)
mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Stories.Archive))
}
override fun onCloseSearchClick() {
@@ -1125,7 +1118,7 @@ class MainActivity :
}
override fun onMegaphoneVisible(megaphone: Megaphone) {
mainNavigationViewModel.onMegaphoneVisible(megaphone)
mainNavigationViewModel.onEvent(MainNavigationEvents.MegaphoneVisible(megaphone))
}
override fun onSnackbarDismissed() = Unit
@@ -1154,11 +1147,11 @@ class MainActivity :
}
override fun onMegaphoneSnooze(event: Megaphones.Event) {
mainNavigationViewModel.onMegaphoneSnoozed(event)
mainNavigationViewModel.onEvent(MainNavigationEvents.MegaphoneSnoozed(event))
}
override fun onMegaphoneCompleted(event: Megaphones.Event) {
mainNavigationViewModel.onMegaphoneCompleted(event)
mainNavigationViewModel.onEvent(MainNavigationEvents.MegaphoneCompleted(event))
}
override fun onMegaphoneDialogFragmentRequested(dialogFragment: DialogFragment) {
@@ -1166,18 +1159,5 @@ class MainActivity :
}
}
private inner class MainNavigationCallback : (MainListRoute) -> Unit {
override fun invoke(location: MainListRoute) {
when (location) {
MainListRoute.Chats -> mainNavigationViewModel.onChatsSelected()
MainListRoute.Calls -> mainNavigationViewModel.onCallsSelected()
MainListRoute.Stories -> mainNavigationViewModel.onStoriesSelected()
MainListRoute.Archive -> mainNavigationViewModel.onArchiveSelected()
}
}
}
override fun goTo(location: MainListRoute) = mainNavigationViewModel.goTo(location)
override fun goTo(location: MainDetailRoute) = mainNavigationViewModel.goTo(location)
override fun exitDetailLocation() = mainNavigationViewModel.exitDetailLocation()
override fun onEvent(event: MainNavigationEvents) = mainNavigationViewModel.onEvent(event)
}
@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.main.MainDetailRoute;
import org.thoughtcrime.securesms.main.MainNavigationEvents;
import org.thoughtcrime.securesms.main.MainNavigationViewModel;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -50,7 +51,7 @@ public class MainNavigator {
.withStartingPosition(startingPosition)
.asIncognito(incognito)
.toConversationArgs())
.subscribe(args -> viewModel.goTo(new MainDetailRoute.Conversation(args)));
.subscribe(args -> viewModel.onEvent(new MainNavigationEvents.GoToDetail(new MainDetailRoute.Conversation(args))));
lifecycleDisposable.add(disposable);
}
@@ -47,7 +47,8 @@ import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationEventSink
import org.thoughtcrime.securesms.main.MainNavigationEvents
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
@@ -63,7 +64,7 @@ fun CallLinkDetailsScreen(
viewModel: CallLinkDetailsViewModel = viewModel {
CallLinkDetailsViewModel(roomId)
},
router: MainNavigationCallDetailRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalActivity.current as ComponentActivity) {
eventSink: MainNavigationEventSink = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalActivity.current as ComponentActivity) {
error("Should already be created.")
}
) {
@@ -72,7 +73,7 @@ fun CallLinkDetailsScreen(
DefaultCallLinkDetailsCallback(
activity = activity,
viewModel = viewModel,
router = router
eventSink = eventSink
)
}
@@ -90,7 +91,7 @@ fun CallLinkDetailsScreen(
class DefaultCallLinkDetailsCallback(
private val activity: FragmentActivity,
private val viewModel: CallLinkDetailsViewModel,
private val router: MainNavigationCallDetailRouter
private val eventSink: MainNavigationEventSink
) : CallLinkDetailsCallback {
private val lifecycleDisposable = LifecycleDisposable()
@@ -113,10 +114,12 @@ class DefaultCallLinkDetailsCallback(
}
override fun onEditNameClicked() {
router.goToCallDetail(
MainDetailRoute.Calls.CallLinks.EditCallLinkName(
callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId(),
currentName = viewModel.nameSnapshot
eventSink.onEvent(
MainNavigationEvents.GoToDetail(
MainDetailRoute.Calls.CallLinks.EditCallLinkName(
callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId(),
currentName = viewModel.nameSnapshot
)
)
)
}
@@ -157,7 +160,7 @@ class DefaultCallLinkDetailsCallback(
viewModel.setDisplayRevocationDialog(false)
activity.lifecycleScope.launch {
if (viewModel.delete()) {
router.exitDetailLocation()
eventSink.onEvent(MainNavigationEvents.ExitDetail)
}
}
}
@@ -51,6 +51,7 @@ import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.main.MainListRoute
import org.thoughtcrime.securesms.main.MainNavigationEvents
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
import org.thoughtcrime.securesms.main.MainToolbarMode
@@ -335,7 +336,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
} else {
mainNavigationViewModel.goTo(MainDetailRoute.CallLinkDetails(callLogRow.record.roomId))
mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.CallLinkDetails(callLogRow.record.roomId)))
}
}
@@ -390,7 +391,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
override fun goToCallLinkDetails(roomId: CallLinkRoomId) {
mainNavigationViewModel.goTo(MainDetailRoute.CallLinkDetails(roomId))
mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.CallLinkDetails(roomId)))
}
override fun deleteCall(call: CallLogRow) {
@@ -64,7 +64,8 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescription
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationEventSink
import org.thoughtcrime.securesms.main.MainNavigationEvents
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.mediapreview.MediaPreviewCache
@@ -117,7 +118,7 @@ class ConversationSettingsFragment : ComposeFragment() {
)
private var transitionCallback: TransitionCallback? = null
private var chatRouter: MainNavigationChatDetailRouter? = null
private var mainNavigationEventSink: MainNavigationEventSink? = null
/** The avatar view owns the shared element transition out of this screen. */
private var avatarView: View? = null
@@ -130,7 +131,7 @@ class ConversationSettingsFragment : ComposeFragment() {
override fun onAttach(context: Context) {
super.onAttach(context)
transitionCallback = context as? TransitionCallback
chatRouter = context as? MainNavigationChatDetailRouter
mainNavigationEventSink = context as? MainNavigationEventSink
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -358,7 +359,7 @@ class ConversationSettingsFragment : ComposeFragment() {
val builder = ConversationIntents.createBuilderSync(requireContext(), action.recipientId, action.threadId)
startActivity(builder.withSearchOpen(action.withSearchOpen).build())
if (action.withSearchOpen && requireActivity() !is MainNavigationChatDetailRouter) {
if (action.withSearchOpen && requireActivity() !is MainNavigationEventSink) {
requireActivity().finish()
}
}
@@ -453,7 +454,7 @@ class ConversationSettingsFragment : ComposeFragment() {
}
is ConversationSettingsAction.OpenGroupConversation -> {
CommunicationActions.startConversation(requireActivity(), action.recipient, null)
if (requireActivity() !is MainNavigationChatDetailRouter) {
if (requireActivity() !is MainNavigationEventSink) {
requireActivity().finish()
}
}
@@ -558,8 +559,9 @@ class ConversationSettingsFragment : ComposeFragment() {
}
private fun goToConversationList() {
if (chatRouter != null) {
chatRouter?.exitDetailLocation()
val eventSink = mainNavigationEventSink
if (eventSink != null) {
eventSink.onEvent(MainNavigationEvents.ExitDetail)
} else {
startActivity(MainActivity.clearTopAndExitDetail(requireContext()))
}
@@ -7,7 +7,8 @@ package org.thoughtcrime.securesms.components.settings.conversation
import androidx.fragment.app.FragmentActivity
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationEventSink
import org.thoughtcrime.securesms.main.MainNavigationEvents
import org.thoughtcrime.securesms.recipients.Recipient
/**
@@ -19,8 +20,8 @@ object ConversationSettingsNavigator {
activity: FragmentActivity,
recipient: Recipient
) {
if (activity is MainNavigationChatDetailRouter) {
activity.goToChatDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id))
if (activity is MainNavigationEventSink) {
activity.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id)))
return
}
@@ -28,7 +28,8 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.jobs.ConversationShortcutUpdateJob
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationEventSink
import org.thoughtcrime.securesms.main.MainNavigationEvents
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import java.util.concurrent.TimeUnit
@@ -36,7 +37,7 @@ import java.util.concurrent.TimeUnit
/**
* Wrapper activity for ConversationFragment.
*/
open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, GooglePayComponent, MainNavigationChatDetailRouter {
open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, GooglePayComponent, MainNavigationEventSink {
companion object {
private val TAG = tag(ConversationActivity::class.java)
@@ -141,13 +142,33 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo
.commitNowAllowingStateLoss()
}
override fun exitDetailLocation() {
/**
* Only the events a conversation displayed on its own can answer. The rest belong to the main window,
* which is where the same screens send them when it is the one hosting them.
*/
override fun onEvent(event: MainNavigationEvents) {
when (event) {
MainNavigationEvents.ExitDetail -> exitDetail()
is MainNavigationEvents.GoToDetail -> {
val route = event.route
if (route is MainDetailRoute.Chats) {
goToChatDetail(route)
} else {
Log.w(TAG, "Cannot display $route outside of the main window. Ignoring it.")
}
}
else -> Log.w(TAG, "Unsupported outside of the main window: $event. Ignoring it.")
}
}
private fun exitDetail() {
if (!supportFragmentManager.popBackStackImmediate()) {
finish()
}
}
override fun goToChatDetail(location: MainDetailRoute.Chats) {
private fun goToChatDetail(location: MainDetailRoute.Chats) {
when (location) {
is MainDetailRoute.Chats.ConversationSettings -> {
lifecycleScope.launch {
@@ -298,7 +298,8 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2
import org.thoughtcrime.securesms.longmessage.LongMessageFragment
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.main.MainListRoute
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationEventSink
import org.thoughtcrime.securesms.main.MainNavigationEvents
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
@@ -581,7 +582,7 @@ class ConversationFragment :
private lateinit var conversationItemDecorations: ConversationItemDecorations
private lateinit var optionsMenuCallback: ConversationOptionsMenuCallback
private lateinit var chatRouter: MainNavigationChatDetailRouter
private lateinit var mainNavigationEventSink: MainNavigationEventSink
private var animationsAllowed = false
private var pinnedShortcutReceiver: BroadcastReceiver? = null
@@ -662,7 +663,7 @@ class ConversationFragment :
override fun onAttach(context: Context) {
super.onAttach(context)
chatRouter = context as MainNavigationChatDetailRouter
mainNavigationEventSink = context as MainNavigationEventSink
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -3004,7 +3005,7 @@ class ConversationFragment :
ConversationDialogs.displayDeleteDialog(requireContext(), recipient) {
messageRequestViewModel
.onDelete()
.doAfterSuccess { chatRouter.exitDetailLocation() }
.doAfterSuccess { mainNavigationEventSink.onEvent(MainNavigationEvents.ExitDetail) }
.subscribeWithShowProgress("delete message request")
}
}
@@ -3151,7 +3152,8 @@ class ConversationFragment :
private fun handleDisplayDetails(conversationMessage: ConversationMessage) {
val recipientSnapshot = viewModel.recipientSnapshot ?: return
chatRouter.goToChatDetail(MainDetailRoute.Chats.MessageDetails(recipientSnapshot.id, MessageId(conversationMessage.messageRecord.id)))
val messageDetails = MainDetailRoute.Chats.MessageDetails(recipientSnapshot.id, MessageId(conversationMessage.messageRecord.id))
mainNavigationEventSink.onEvent(MainNavigationEvents.GoToDetail(messageDetails))
}
private fun handleDeleteMessages(messageParts: Set<MultiselectPart>) {
@@ -3707,7 +3709,7 @@ class ConversationFragment :
} else if (messageRecord.hasFailedWithNetworkFailures()) {
ConversationDialogs.displayMessageCouldNotBeSentDialog(requireContext(), messageRecord)
} else {
chatRouter.goToChatDetail(MainDetailRoute.Chats.MessageDetails(recipientId, MessageId(messageRecord.id)))
mainNavigationEventSink.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Chats.MessageDetails(recipientId, MessageId(messageRecord.id))))
}
}
@@ -4388,7 +4390,7 @@ class ConversationFragment :
override fun handleManageGroup() {
viewModel.recipientSnapshot?.let { recipient ->
container.hideKeyboard(composeText)
chatRouter.goToChatDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id))
mainNavigationEventSink.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id)))
}
}
@@ -4426,7 +4428,7 @@ class ConversationFragment :
viewModel.recipientSnapshot?.let { recipient ->
if (!viewModel.hasMessageRequestState || recipient.isBlocked) {
container.hideKeyboard(composeText)
chatRouter.goToChatDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id))
mainNavigationEventSink.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id)))
}
}
}
@@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.snackbars.SnackbarState;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.main.MainListRoute;
import org.thoughtcrime.securesms.main.MainNavigationEvents;
import org.thoughtcrime.securesms.main.MainSnackbarHostKey;
import org.thoughtcrime.securesms.util.ConversationUtil;
@@ -77,7 +78,7 @@ public class ConversationListArchiveFragment extends ConversationListFragment
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
mainNavigationViewModel.goTo(MainListRoute.Chats);
mainNavigationViewModel.onEvent(new MainNavigationEvents.GoToList(MainListRoute.Chats));
}
});
}
@@ -144,6 +144,7 @@ import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob;
import org.thoughtcrime.securesms.keyvalue.AccountValues;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.main.MainListRoute;
import org.thoughtcrime.securesms.main.MainNavigationEvents;
import org.thoughtcrime.securesms.main.MainNavigationViewModel;
import org.thoughtcrime.securesms.main.MainSnackbarHostKey;
import org.thoughtcrime.securesms.main.MainToolbarMode;
@@ -702,7 +703,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
@Override
public void onShowArchiveClick() {
if (viewModel.currentSelectedConversations().isEmpty()) {
mainNavigationViewModel.goTo(MainListRoute.Archive);
mainNavigationViewModel.onEvent(new MainNavigationEvents.GoToList(MainListRoute.Archive));
}
}
@@ -765,7 +766,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
} else if (event instanceof MainToolbarViewModel.Event.Chats.ClearFilter) {
onClearFilterClick();
} else if (event instanceof MainToolbarViewModel.Event.Chats.CloseArchive) {
mainNavigationViewModel.goTo(MainListRoute.Chats);
mainNavigationViewModel.onEvent(new MainNavigationEvents.GoToList(MainListRoute.Chats));
}
})
);
@@ -0,0 +1,17 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
/**
* An activity that hosts main navigation content and can answer its events.
*
* The main window hands them to [MainNavigationViewModel]. The standalone conversation activity answers
* the few that still mean something outside the main window with its own fragment transactions. Screens
* that either one can host send their events here, and check for it to tell the two hosts apart.
*/
fun interface MainNavigationEventSink {
fun onEvent(event: MainNavigationEvents)
}
@@ -0,0 +1,52 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import org.signal.core.ui.compose.split.ListDetailEvents
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.Megaphones
/**
* UI Events to drive the main navigation view model.
*/
sealed interface MainNavigationEvents {
/**
* The user clicked [tab] in the navigation bar or rail. Clicking the tab already displayed reveals its
* list and tells that tab's screen about the click, rather than navigating anywhere.
*/
data class GoToTab(val tab: MainListRoute) : MainNavigationEvents
/** Display [route], which comes back to whatever detail content its tab had open. */
data class GoToList(val route: MainListRoute) : MainNavigationEvents
/** Open [route] above the list of whichever tab owns it. */
data class GoToDetail(val route: MainDetailRoute) : MainNavigationEvents
/** Drop the detail above the displayed list, leaving that list on its own. */
data object ExitDetail : MainNavigationEvents
/** Open the camera straight into story capture. */
data object GoToCameraFirstStoryCapture : MainNavigationEvents
/** Re-read the settings that shape the navigation bar. */
data object RefreshNavigationBar : MainNavigationEvents
/** Ask for the next megaphone to display, if there is one. */
data object RequestNextMegaphone : MainNavigationEvents
/** [megaphone] was displayed to the user. */
data class MegaphoneVisible(val megaphone: Megaphone) : MainNavigationEvents
/** The user put [event]'s megaphone off until later. */
data class MegaphoneSnoozed(val event: Megaphones.Event) : MainNavigationEvents
/** [event]'s megaphone is done with, and should not come back. */
data class MegaphoneCompleted(val event: Megaphones.Event) : MainNavigationEvents
/** Something that is navigation and nothing else, so the navigator can answer it unaided. */
data class ListDetailEvent(val event: ListDetailEvents) : MainNavigationEvents
}
@@ -1,33 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
/**
* Handles navigation for sub-screens within the chats detail pane.
*/
interface MainNavigationChatDetailRouter {
fun exitDetailLocation()
fun goToChatDetail(location: MainDetailRoute.Chats)
}
/**
* Handles navigation for sub-screens within the calls detail pane.
*/
interface MainNavigationCallDetailRouter {
fun exitDetailLocation()
fun goToCallDetail(location: MainDetailRoute.Calls)
}
/**
* Handles navigation to all [MainListRoute]s and [MainDetailRoute]s, including the top-level roots.
*/
interface MainNavigationRouter : MainNavigationChatDetailRouter, MainNavigationCallDetailRouter {
fun goTo(location: MainListRoute)
fun goTo(location: MainDetailRoute)
override fun goToChatDetail(location: MainDetailRoute.Chats) = goTo(location)
override fun goToCallDetail(location: MainDetailRoute.Calls) = goTo(location)
}
@@ -27,6 +27,7 @@ import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.rx3.asObservable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.ui.compose.split.ListDetailEvents
import org.signal.core.ui.compose.split.ListDetailNavigator
import org.signal.core.ui.compose.split.PaneAnchor
@@ -49,7 +50,7 @@ import kotlin.time.Duration.Companion.milliseconds
class MainNavigationViewModel(
savedStateHandle: SavedStateHandle,
initialListLocation: MainListRoute = MainListRoute.Chats
) : ViewModel(), MainNavigationRouter {
) : EventDrivenViewModel<MainNavigationEvents>(TAG), MainNavigationEventSink {
companion object {
private val TAG = Log.tag(MainNavigationViewModel::class)
@@ -156,11 +157,20 @@ class MainNavigationViewModel(
}
}
/**
* The user dragged the pane divider to [anchor].
*/
fun onPaneAnchorSelected(anchor: PaneAnchor) {
navigator.processEvent(ListDetailEvents.AnchorSelected(anchor))
override suspend fun processEvent(event: MainNavigationEvents) {
when (event) {
is MainNavigationEvents.GoToTab -> goToTab(event.tab)
is MainNavigationEvents.GoToList -> goToList(event.route)
is MainNavigationEvents.GoToDetail -> goToDetail(event.route)
MainNavigationEvents.ExitDetail -> navigator.processEvent(ListDetailEvents.ExitDetail)
MainNavigationEvents.GoToCameraFirstStoryCapture -> internalNavigationEvents.emit(NavigationEvent.STORY_CAMERA_FIRST)
MainNavigationEvents.RefreshNavigationBar -> refreshNavigationBarState()
MainNavigationEvents.RequestNextMegaphone -> requestNextMegaphone()
is MainNavigationEvents.MegaphoneVisible -> megaphoneRepository.markVisible(event.megaphone.event)
is MainNavigationEvents.MegaphoneSnoozed -> onMegaphoneSnoozed(event.event)
is MainNavigationEvents.MegaphoneCompleted -> onMegaphoneCompleted(event.event)
is MainNavigationEvents.ListDetailEvent -> navigator.processEvent(event.event)
}
}
/** Set from the MainActivity composition, and cleared when it is disposed. */
@@ -168,7 +178,38 @@ class MainNavigationViewModel(
captureChatListSnapshot = capture
}
override fun goTo(location: MainDetailRoute) = setDetailLocation(location)
/**
* Clicking the tab already displayed reveals its list and tells that tab's screen about the click
* scrolling to the top, and the like rather than navigating anywhere.
*/
private suspend fun goToTab(tab: MainListRoute) {
if (navigator.displayedList.value == tab) {
navigator.processEvent(ListDetailEvents.RevealList)
internalTabClickEvents.emit(tab)
} else {
goToList(tab)
}
}
/** Switching tabs only changes which stack is displayed; each tab comes back to whatever it had open. */
private fun goToList(route: MainListRoute) {
navigator.processEvent(
ListDetailEvents.GoToList(
listRoute = route,
root = route.tab,
push = route == MainListRoute.Archive
)
)
}
private suspend fun goToDetail(route: MainDetailRoute) {
when (route) {
is MainDetailRoute.Chats -> pushChatsDetailLocation(route)
is MainDetailRoute.Conversation -> goToConversation(route)
is MainDetailRoute.Calls, is MainDetailRoute.CallLinkDetails -> pushCallsDetailLocation(route)
is MainDetailRoute.Stories -> navigator.processEvent(ListDetailEvents.Push(route, MainListRoute.Stories))
}
}
private suspend fun MainDetailRoute.Conversation.withPreloadedWallpaper(): MainDetailRoute.Conversation {
val args = conversationArgs
@@ -196,33 +237,15 @@ class MainNavigationViewModel(
return copy(conversationArgs = updatedArgs)
}
private fun setDetailLocation(location: MainDetailRoute) {
when (location) {
is MainDetailRoute.Chats -> pushChatsDetailLocation(location)
is MainDetailRoute.Conversation -> goToConversation(location)
is MainDetailRoute.Calls, is MainDetailRoute.CallLinkDetails -> pushCallsDetailLocation(location)
is MainDetailRoute.Stories -> pushStoriesDetailLocation(location)
}
}
/**
* Drops the detail content above the current list, leaving that list displayed on its own.
*/
override fun exitDetailLocation() {
navigator.processEvent(ListDetailEvents.ExitDetail)
}
private fun goToConversation(location: MainDetailRoute.Conversation) {
private suspend fun goToConversation(location: MainDetailRoute.Conversation) {
val captureSnapshot = captureChatListSnapshot
if (captureSnapshot == null) {
// share intent or process restore - push synchronously, since there's no chat-list snapshot to capture and no need to preload a wallpaper
// share intent or process restore - there's no chat-list snapshot to capture and no need to preload a wallpaper
pushChatsDetailLocation(location)
} else {
viewModelScope.launch {
captureSnapshot()
pushChatsDetailLocation(location.withPreloadedWallpaper())
}
captureSnapshot()
pushChatsDetailLocation(location.withPreloadedWallpaper())
}
}
@@ -244,53 +267,23 @@ class MainNavigationViewModel(
navigator.processEvent(ListDetailEvents.Push(location, MainListRoute.Calls))
}
private fun pushStoriesDetailLocation(location: MainDetailRoute) {
navigator.processEvent(ListDetailEvents.Push(location, MainListRoute.Stories))
}
/** Pops the stack belonging to whichever tab the user is currently on. */
fun popCurrentDetailLocation() {
navigator.processEvent(ListDetailEvents.Back)
}
/** Switching tabs only changes which stack is displayed; each tab comes back to whatever it had open. */
override fun goTo(location: MainListRoute) {
navigator.processEvent(
ListDetailEvents.GoToList(
listRoute = location,
root = location.tab,
push = location == MainListRoute.Archive
)
)
}
fun goToCameraFirstStoryCapture() {
viewModelScope.launch {
internalNavigationEvents.emit(NavigationEvent.STORY_CAMERA_FIRST)
}
}
fun getNextMegaphone() {
private fun requestNextMegaphone() {
megaphoneRepository.getNextMegaphone { next ->
internalMegaphone.update { next ?: Megaphone.NONE }
}
}
fun onMegaphoneSnoozed(event: Megaphones.Event) {
private fun onMegaphoneSnoozed(event: Megaphones.Event) {
megaphoneRepository.markInteractedWith(event)
internalMegaphone.update { Megaphone.NONE }
}
fun onMegaphoneCompleted(event: Megaphones.Event) {
private fun onMegaphoneCompleted(event: Megaphones.Event) {
internalMegaphone.update { Megaphone.NONE }
megaphoneRepository.markFinished(event)
}
fun onMegaphoneVisible(visible: Megaphone) {
megaphoneRepository.markVisible(visible.event)
}
fun refreshNavigationBarState() {
private fun refreshNavigationBarState() {
internalMainNavigationBarState.update {
it.copy(
compact = SignalStore.settings.useCompactNavigationBar,
@@ -305,34 +298,6 @@ class MainNavigationViewModel(
return notificationProfilesRepository.getProfiles().asFlow()
}
fun onChatsSelected() {
onTabSelected(MainListRoute.Chats)
}
fun onArchiveSelected() {
onTabSelected(MainListRoute.Archive)
}
fun onCallsSelected() {
onTabSelected(MainListRoute.Calls)
}
fun onStoriesSelected() {
onTabSelected(MainListRoute.Stories)
}
private fun onTabSelected(destination: MainListRoute) {
viewModelScope.launch {
val displayed = navigator.displayedList.value
if (displayed == destination) {
navigator.processEvent(ListDetailEvents.RevealList)
internalTabClickEvents.emit(destination)
} else {
goTo(destination)
}
}
}
private fun <T : Any> performStoreUpdate(flow: Flow<T>, fn: (T, MainNavigationBarState) -> MainNavigationBarState) {
viewModelScope.launch {
flow.collectLatest { item ->
@@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.main.MainListRoute
import org.thoughtcrime.securesms.main.MainNavigationEvents
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
import org.thoughtcrime.securesms.main.MainToolbarViewModel
@@ -188,7 +189,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
MyStoriesItem.Model(
lifecycleOwner = viewLifecycleOwner,
onClick = {
mainNavigationViewModel.goToCameraFirstStoryCapture()
mainNavigationViewModel.onEvent(MainNavigationEvents.GoToCameraFirstStoryCapture)
}
)
)
@@ -258,7 +259,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
openStoryViewer(model, preview, true)
},
onAvatarClick = {
mainNavigationViewModel.goToCameraFirstStoryCapture()
mainNavigationViewModel.onEvent(MainNavigationEvents.GoToCameraFirstStoryCapture)
},
onLockList = {
recyclerView?.suppressLayout(true)
@@ -271,7 +272,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
private fun openStoryViewer(model: StoriesLandingItem.Model, preview: View, isFromInfoContextMenuAction: Boolean) {
if (model.data.storyRecipient.isMyStory) {
mainNavigationViewModel.goTo(MainDetailRoute.Stories.MyStories)
mainNavigationViewModel.onEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Stories.MyStories))
} else if (model.data.primaryStory.messageRecord.isOutgoing && model.data.primaryStory.messageRecord.isFailed) {
if (model.data.primaryStory.messageRecord.isIdentityMismatchFailure) {
SafetyNumberBottomSheet
@@ -25,6 +25,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.compose.split.ListDetailEvents
import org.signal.core.ui.compose.split.PaneAnchor
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageId
@@ -33,9 +34,8 @@ import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
/**
* Covers the view model as the sole owner of the tab back stacks: everything a screen can ask for goes
* through [MainNavigationRouter] or [MainNavigationViewModel.popCurrentDetailLocation], and the stacks it
* hands the display are what those calls leave behind.
* Covers the view model as the sole owner of the tab back stacks: everything a screen can ask for arrives
* as a [MainNavigationEvents], and the stacks it hands the display are what those events leave behind.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@@ -79,6 +79,12 @@ class MainNavigationViewModelTest {
Dispatchers.resetMain()
}
/** Events are answered off the view model's own channel, so every one of them is drained before asserting. */
private fun MainNavigationViewModel.sendEvent(event: MainNavigationEvents) {
onEvent(event)
testDispatcher.scheduler.advanceUntilIdle()
}
@Test
fun `given a new view model, then chats is displayed with no detail`() {
assertEquals(MainListRoute.Chats, viewModel.currentTab.value)
@@ -87,41 +93,41 @@ class MainNavigationViewModelTest {
@Test
fun `when going to a detail location, then it is pushed onto the displayed tab`() {
viewModel.goTo(conversationSettings)
viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings))
assertEquals(listOf(MainListRoute.Chats, conversationSettings), viewModel.navigator[MainListRoute.Chats])
}
@Test
fun `given stacked detail, when popping, then only the top of the stack is dropped`() {
viewModel.goTo(conversationSettings)
viewModel.goTo(messageDetails)
viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(messageDetails))
viewModel.popCurrentDetailLocation()
viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back))
assertEquals(listOf(MainListRoute.Chats, conversationSettings), viewModel.navigator[MainListRoute.Chats])
}
@Test
fun `given stacked detail, when exiting detail, then all of it is dropped`() {
viewModel.goTo(conversationSettings)
viewModel.goTo(messageDetails)
viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(messageDetails))
viewModel.exitDetailLocation()
viewModel.sendEvent(MainNavigationEvents.ExitDetail)
assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats])
}
@Test
fun `given a stack at its root, when popping, then nothing is dropped`() {
viewModel.popCurrentDetailLocation()
viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back))
assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats])
}
@Test
fun `when going to the archive, then it is pushed onto the chats stack rather than becoming a tab`() {
viewModel.goTo(MainListRoute.Archive)
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Archive))
assertEquals(MainListRoute.Chats, viewModel.currentTab.value)
assertEquals(
@@ -132,9 +138,9 @@ class MainNavigationViewModelTest {
@Test
fun `given the archive is displayed, when going back to chats, then the archive is popped`() {
viewModel.goTo(MainListRoute.Archive)
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Archive))
viewModel.goTo(MainListRoute.Chats)
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Chats))
assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats])
}
@@ -145,10 +151,10 @@ class MainNavigationViewModelTest {
*/
@Test
fun `given detail opened from the archive, when exiting detail, then the archive stays displayed`() {
viewModel.goTo(MainListRoute.Archive)
viewModel.goTo(conversationSettings)
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Archive))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings))
viewModel.exitDetailLocation()
viewModel.sendEvent(MainNavigationEvents.ExitDetail)
assertEquals(
listOf(MainListRoute.Chats, MainListRoute.Archive),
@@ -158,9 +164,9 @@ class MainNavigationViewModelTest {
@Test
fun `given detail is open, when opening the archive, then the detail stays displayed above it`() {
viewModel.goTo(conversationSettings)
viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings))
viewModel.goTo(MainListRoute.Archive)
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Archive))
assertEquals(
listOf(MainListRoute.Chats, MainListRoute.Archive, conversationSettings),
@@ -170,8 +176,8 @@ class MainNavigationViewModelTest {
@Test
fun `when going to a calls destination, then it is pushed onto the calls stack`() {
viewModel.goTo(MainListRoute.Calls)
viewModel.goTo(callLinkDetails)
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Calls))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(callLinkDetails))
assertEquals(MainListRoute.Calls, viewModel.currentTab.value)
assertEquals(listOf(MainListRoute.Calls, callLinkDetails), viewModel.navigator[MainListRoute.Calls])
@@ -183,11 +189,11 @@ class MainNavigationViewModelTest {
*/
@Test
fun `given detail open on another tab, when switching away and back, then that stack is unchanged`() {
viewModel.goTo(MainListRoute.Calls)
viewModel.goTo(callLinkDetails)
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Calls))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(callLinkDetails))
viewModel.goTo(MainListRoute.Chats)
viewModel.goTo(MainListRoute.Calls)
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Chats))
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Calls))
assertEquals(listOf(MainListRoute.Calls, callLinkDetails), viewModel.navigator[MainListRoute.Calls])
}
@@ -198,12 +204,12 @@ class MainNavigationViewModelTest {
*/
@Test
fun `given detail open on both tabs, when popping from calls, then only the calls stack is affected`() {
viewModel.goTo(MainListRoute.Chats)
viewModel.goTo(conversationSettings)
viewModel.goTo(MainListRoute.Calls)
viewModel.goTo(callLinkDetails)
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Chats))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings))
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Calls))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(callLinkDetails))
viewModel.popCurrentDetailLocation()
viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back))
assertEquals(listOf(MainListRoute.Calls), viewModel.navigator[MainListRoute.Calls])
assertEquals(listOf(MainListRoute.Chats, conversationSettings), viewModel.navigator[MainListRoute.Chats])
@@ -211,12 +217,12 @@ class MainNavigationViewModelTest {
@Test
fun `given detail open on both tabs, when exiting detail from calls, then only the calls stack is affected`() {
viewModel.goTo(MainListRoute.Chats)
viewModel.goTo(conversationSettings)
viewModel.goTo(MainListRoute.Calls)
viewModel.goTo(callLinkDetails)
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Chats))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings))
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Calls))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(callLinkDetails))
viewModel.exitDetailLocation()
viewModel.sendEvent(MainNavigationEvents.ExitDetail)
assertEquals(listOf(MainListRoute.Calls), viewModel.navigator[MainListRoute.Calls])
assertEquals(listOf(MainListRoute.Chats, conversationSettings), viewModel.navigator[MainListRoute.Chats])
@@ -224,12 +230,12 @@ class MainNavigationViewModelTest {
@Test
fun `given detail open on another tab, when popping from chats, then only the chats stack is affected`() {
viewModel.goTo(MainListRoute.Calls)
viewModel.goTo(callLinkDetails)
viewModel.goTo(MainListRoute.Chats)
viewModel.goTo(conversationSettings)
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Calls))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(callLinkDetails))
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Chats))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings))
viewModel.popCurrentDetailLocation()
viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back))
assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats])
assertEquals(listOf(MainListRoute.Calls, callLinkDetails), viewModel.navigator[MainListRoute.Calls])
@@ -237,8 +243,8 @@ class MainNavigationViewModelTest {
@Test
fun `when going to a stories destination, then it is pushed onto the stories stack`() {
viewModel.goTo(MainListRoute.Stories)
viewModel.goTo(MainDetailRoute.Stories.MyStories)
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Stories))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Stories.MyStories))
assertEquals(
listOf(MainListRoute.Stories, MainDetailRoute.Stories.MyStories),
@@ -252,10 +258,10 @@ class MainNavigationViewModelTest {
*/
@Test
fun `given a stories destination is open, when opening another, then it replaces the first`() {
viewModel.goTo(MainListRoute.Stories)
viewModel.goTo(MainDetailRoute.Stories.MyStories)
viewModel.sendEvent(MainNavigationEvents.GoToList(MainListRoute.Stories))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Stories.MyStories))
viewModel.goTo(MainDetailRoute.Stories.PrivacySettings)
viewModel.sendEvent(MainNavigationEvents.GoToDetail(MainDetailRoute.Stories.PrivacySettings))
assertEquals(
listOf(MainListRoute.Stories, MainDetailRoute.Stories.PrivacySettings),
@@ -265,30 +271,30 @@ class MainNavigationViewModelTest {
@Test
fun `given the list fills the window, when detail content opens, then the detail is revealed`() {
viewModel.onPaneAnchorSelected(PaneAnchor.LIST_ONLY)
viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.AnchorSelected(PaneAnchor.LIST_ONLY)))
viewModel.goTo(conversationSettings)
viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings))
assertEquals(PaneAnchor.DETAIL_ONLY, viewModel.paneAnchor.value)
}
@Test
fun `given the detail fills the window, when the last detail is popped, then the list is revealed`() {
viewModel.goTo(conversationSettings)
viewModel.onPaneAnchorSelected(PaneAnchor.DETAIL_ONLY)
viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings))
viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.AnchorSelected(PaneAnchor.DETAIL_ONLY)))
viewModel.popCurrentDetailLocation()
viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back))
assertEquals(PaneAnchor.LIST_ONLY, viewModel.paneAnchor.value)
}
@Test
fun `given stacked detail, when the top is popped, then the detail pane stays revealed`() {
viewModel.goTo(conversationSettings)
viewModel.goTo(messageDetails)
viewModel.onPaneAnchorSelected(PaneAnchor.DETAIL_ONLY)
viewModel.sendEvent(MainNavigationEvents.GoToDetail(conversationSettings))
viewModel.sendEvent(MainNavigationEvents.GoToDetail(messageDetails))
viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.AnchorSelected(PaneAnchor.DETAIL_ONLY)))
viewModel.popCurrentDetailLocation()
viewModel.sendEvent(MainNavigationEvents.ListDetailEvent(ListDetailEvents.Back))
assertEquals(PaneAnchor.DETAIL_ONLY, viewModel.paneAnchor.value)
}