From 4ab4bb458aaceea88f7dd2606718cd9c89c75c7e Mon Sep 17 00:00:00 2001 From: Jeffrey Starke Date: Tue, 7 Jul 2026 17:10:23 -0400 Subject: [PATCH] Migrate stories tab to nav 3. --- .../main/MainNavigationLaunchTest.kt | 14 +- .../thoughtcrime/securesms/MainActivity.kt | 70 ++----- .../securesms/main/EmptyDetailScreen.kt | 41 +++++ .../securesms/main/MainActivityComponents.kt | 171 ------------------ .../main/MainNavigationDetailLocation.kt | 29 ++- .../securesms/main/MainNavigationViewModel.kt | 65 ++++--- .../securesms/main/StoriesNavHost.kt | 19 -- .../securesms/stories/StoriesBackStack.kt | 51 ++++++ .../securesms/stories/StoriesNavigation.kt | 120 ++++++++++++ .../stories/landing/StoriesLandingFragment.kt | 10 +- .../securesms/stories/my/MyStoriesFragment.kt | 6 +- .../settings/StorySettingsNavHostFragment.kt | 39 ++++ .../story/StoriesPrivacySettingsFragment.kt | 11 +- 13 files changed, 344 insertions(+), 302 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/EmptyDetailScreen.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainActivityComponents.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/StoriesNavHost.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/StoriesBackStack.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/StoriesNavigation.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/settings/StorySettingsNavHostFragment.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/main/MainNavigationLaunchTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/main/MainNavigationLaunchTest.kt index 233b79b666..71ffd211ea 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/main/MainNavigationLaunchTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/main/MainNavigationLaunchTest.kt @@ -49,7 +49,6 @@ import java.util.concurrent.TimeUnit */ @RunWith(AndroidJUnit4::class) class MainNavigationLaunchTest { - @get:Rule val harness = SignalActivityRule(othersCount = 2) @@ -89,7 +88,8 @@ class MainNavigationLaunchTest { appendLine("fragments observed: ${recorder.allCreated}") appendLine("activity fragments: ${launched.activity.supportFragmentManager.fragments.map { it::class.simpleName }}") appendLine("vm.currentListLocation: ${vm.mainNavigationState.value.currentListLocation}") - appendLine("vm.earlyNavigationDetailLocationRequested: ${vm.earlyNavigationDetailLocationRequested}") + appendLine("vm.detailLocation: ${vm.detailLocation.value}") + appendLine("vm.chatsBackStackEntries: ${vm.chatsBackStackEntries.toList()}") } } throw IllegalStateException("${e.message}\n$state", e) @@ -254,8 +254,9 @@ class MainNavigationLaunchTest { "starts handling it on cold launch, update or delete this test. Got: ${recorder.allCreated}" } val vm = runOnMainSync { launched.activity.mainNavigationViewModel() } - check(vm.earlyNavigationDetailLocationRequested == null) { - "Expected no early detail to be staged, got ${vm.earlyNavigationDetailLocationRequested}" + val staged = runOnMainSync { vm.chatsBackStackEntries.filterNot { it is MainNavigationDetailLocation.Empty } } + check(staged.isEmpty()) { + "Expected no detail to be staged on the chats back stack, got $staged" } } } @@ -289,8 +290,9 @@ class MainNavigationLaunchTest { "Expected default CHATS, got ${vm.mainNavigationState.value.currentListLocation}" } Thread.sleep(750) - check(vm.earlyNavigationDetailLocationRequested == null) { - "Expected no early detail, got ${vm.earlyNavigationDetailLocationRequested}" + val detailLocation = runOnMainSync { vm.detailLocation.value } + check(detailLocation == MainNavigationDetailLocation.Empty) { + "Expected Empty detail location, got $detailLocation" } check(recorder.createdArgs.isEmpty()) { "Expected no ConversationFragment for bare launch, got ${recorder.createdArgs.size}" diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index b83af048f8..7b732160f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -145,7 +145,6 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity -import org.thoughtcrime.securesms.main.DetailsScreenNavHost import org.thoughtcrime.securesms.main.MainBottomChrome import org.thoughtcrime.securesms.main.MainBottomChromeCallback import org.thoughtcrime.securesms.main.MainBottomChromeState @@ -165,10 +164,6 @@ import org.thoughtcrime.securesms.main.MainToolbarMode import org.thoughtcrime.securesms.main.MainToolbarState import org.thoughtcrime.securesms.main.MainToolbarViewModel import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder -import org.thoughtcrime.securesms.main.navigateToDetailLocation -import org.thoughtcrime.securesms.main.rememberDetailNavHostController -import org.thoughtcrime.securesms.main.rememberFocusRequester -import org.thoughtcrime.securesms.main.storiesNavGraphBuilder import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher import org.thoughtcrime.securesms.megaphone.Megaphone @@ -183,9 +178,8 @@ import org.thoughtcrime.securesms.service.BackupMediaRestoreService import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.starred.StarredMessagesActivity import org.thoughtcrime.securesms.stories.Stories -import org.thoughtcrime.securesms.stories.archive.StoryArchiveActivity import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment -import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity +import org.thoughtcrime.securesms.stories.storiesNavEntries import org.thoughtcrime.securesms.util.AppStartup import org.thoughtcrime.securesms.util.CachedInflater import org.thoughtcrime.securesms.util.CommunicationActions @@ -506,50 +500,6 @@ class MainActivity : mainNavigationViewModel.onSplitPaneChanged(isSplitPane) } - val storiesNavHostController = rememberDetailNavHostController( - onRequestFocus = rememberFocusRequester( - mainNavigationViewModel = mainNavigationViewModel, - currentListLocation = mainNavigationState.currentListLocation - ) { it == MainNavigationListLocation.STORIES } - ) { - storiesNavGraphBuilder() - } - - LaunchedEffect(Unit) { - fun navigateToLocation(location: MainNavigationDetailLocation) { - when (location) { - is MainNavigationDetailLocation.Empty -> { - when (mainNavigationState.currentListLocation) { - MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> { - throw IllegalStateException("Navigation to ${mainNavigationState.currentListLocation} should be handled by ChatsBackStack.") - } - - MainNavigationListLocation.CALLS -> { - throw IllegalStateException("Navigation to ${MainNavigationListLocation.CALLS} should be handled by CallsBackStack.") - } - - MainNavigationListLocation.STORIES -> storiesNavHostController.navigateToDetailLocation(location) - } - } - - is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> { - throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.") - } - - is MainNavigationDetailLocation.CallLinkDetails, is MainNavigationDetailLocation.Calls -> { - throw IllegalStateException("Navigation to $location should be handled by CallsBackStack.") - } - - is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location) - } - } - - mainNavigationViewModel.earlyNavigationDetailLocationRequested?.let { navigateToLocation(it) } - mainNavigationViewModel.clearEarlyDetailLocation() - - mainNavigationViewModel.detailLocation.collect { navigateToLocation(it) } - } - val scope = rememberCoroutineScope() BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) { @@ -749,9 +699,17 @@ class MainActivity : } MainNavigationListLocation.STORIES -> { - DetailsScreenNavHost( - navHostController = storiesNavHostController, - contentLayoutData = contentLayoutData + NavDisplay( + backStack = mainNavigationViewModel.storiesBackStackEntries, + onBack = { mainNavigationViewModel.popStoriesDetailLocation() }, + transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec, + popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec, + predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { storiesNavEntries() } ) } } @@ -1269,11 +1227,11 @@ class MainActivity : } override fun onStoryPrivacyClick() { - startActivity(StorySettingsActivity.getIntent(this@MainActivity)) + mainNavigationViewModel.goTo(MainNavigationDetailLocation.Stories.PrivacySettings) } override fun onStoryArchiveClick() { - startActivity(StoryArchiveActivity.createIntent(this@MainActivity)) + mainNavigationViewModel.goTo(MainNavigationDetailLocation.Stories.Archive) } override fun onCloseSearchClick() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/EmptyDetailScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/main/EmptyDetailScreen.kt new file mode 100644 index 0000000000..3494c7858b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/EmptyDetailScreen.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.main + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import org.thoughtcrime.securesms.R + +/** + * Displayed when the user has not selected content for a given tab. + */ +@Composable +fun EmptyDetailScreen() { + Box( + modifier = Modifier + .background(color = MaterialTheme.colorScheme.surface) + .fillMaxSize() + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_signal_logo_large), + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.32f), + modifier = Modifier + .size(80.dp) + .align(Alignment.Center) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityComponents.kt deleted file mode 100644 index eaf5d80c4d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityComponents.kt +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.main - -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import androidx.navigation.createGraph -import org.thoughtcrime.securesms.R - -/** - * Displayed when the user has not selected content for a given tab. - */ -@Composable -fun EmptyDetailScreen() { - Box( - modifier = Modifier - .background(color = MaterialTheme.colorScheme.surface) - .fillMaxSize() - ) { - Icon( - painter = painterResource(R.drawable.ic_signal_logo_large), - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.32f), - modifier = Modifier - .size(80.dp) - .align(Alignment.Center) - ) - } -} - -/** - * Emits [MainNavigationDetailLocation] whenever a change occurs, and persists the latest value. - * - * In order to ensure proper behaviour when moving from the inner to outer screen, and to ensure we don't accidentally end up - * back on an unexpected Empty screen, we utilize a LaunchedEffect that subscribes to our detailLocation Flow instead of directly - * utilizing collectAsStateWithLifecycle. Then the latest value is remembered as a saveable using the default [MainNavigationDetailLocation.Saver] - */ -@Composable -fun MainNavigationDetailLocationEffect( - mainNavigationViewModel: MainNavigationViewModel, - onWillFocusPrimary: suspend () -> Unit = {} -) { - val state = rememberSaveable( - stateSaver = MainNavigationDetailLocation.Saver( - mainNavigationViewModel.earlyNavigationDetailLocationRequested - ) - ) { - mutableStateOf(mainNavigationViewModel.earlyNavigationDetailLocationRequested ?: MainNavigationDetailLocation.Empty) - } - - LaunchedEffect(Unit) { - mainNavigationViewModel.detailLocation.collect { - if (state.value == it) { - mainNavigationViewModel.setFocusedPane( - if (it == MainNavigationDetailLocation.Empty) { - ThreePaneScaffoldRole.Secondary - } else { - if (it.isContentRoot) { - onWillFocusPrimary() - } - ThreePaneScaffoldRole.Primary - } - ) - } - - state.value = it - } - } -} - -@Composable -fun rememberFocusRequester( - mainNavigationViewModel: MainNavigationViewModel, - currentListLocation: MainNavigationListLocation, - isTargetListLocation: (MainNavigationListLocation) -> Boolean -): (ThreePaneScaffoldRole) -> Unit { - return remember(currentListLocation, isTargetListLocation, mainNavigationViewModel) { - if (isTargetListLocation(currentListLocation)) { - { - mainNavigationViewModel.setFocusedPane(it) - } - } else { - {} - } - } -} - -@Composable -fun rememberDetailNavHostController( - onRequestFocus: (ThreePaneScaffoldRole) -> Unit, - builder: NavGraphBuilder.(NavHostController) -> Unit -): NavHostController { - val navHostController = rememberNavController() - val viewModelStore = LocalViewModelStoreOwner.current!!.viewModelStore - - remember { - val graph = navHostController.createGraph( - startDestination = MainNavigationDetailLocation.Empty, - builder = { builder(navHostController) } - ) - - navHostController.setViewModelStore(viewModelStore) - navHostController.setGraph(graph, null) - graph - } - - val entry by navHostController.currentBackStackEntryAsState() - LaunchedEffect(entry) { - if (entry != null && entry?.destination?.route != MainNavigationDetailLocation.Empty::class.qualifiedName) { - onRequestFocus(ThreePaneScaffoldRole.Primary) - } else { - onRequestFocus(ThreePaneScaffoldRole.Secondary) - } - } - - return navHostController -} - -fun NavHostController.navigateToDetailLocation(location: MainNavigationDetailLocation) { - navigate(location) { - launchSingleTop = true - if (location.isContentRoot) { - popUpTo(graph.id) { inclusive = true } - } - } -} - -@Composable -fun DetailsScreenNavHost(navHostController: NavHostController, contentLayoutData: MainContentLayoutData) { - NavHost( - navController = navHostController, - graph = navHostController.graph, - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None }, - popEnterTransition = { EnterTransition.None }, - popExitTransition = { ExitTransition.None }, - modifier = Modifier - .padding(end = contentLayoutData.detailPaddingEnd) - .clip(contentLayoutData.shape) - .background(color = MaterialTheme.colorScheme.surface) - .fillMaxSize() - ) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt index 6484f9f26b..6cdae0fde1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt @@ -6,13 +6,11 @@ package org.thoughtcrime.securesms.main import android.os.Parcelable -import androidx.compose.runtime.saveable.SaverScope import androidx.navigation3.runtime.NavKey import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import kotlinx.serialization.Transient -import kotlinx.serialization.json.Json import org.thoughtcrime.securesms.calls.log.CallLogRow import org.thoughtcrime.securesms.conversation.ConversationArgs import org.thoughtcrime.securesms.database.model.MessageId @@ -26,18 +24,6 @@ import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId @Parcelize sealed interface MainNavigationDetailLocation : Parcelable, NavKey { - class Saver( - val earlyLocation: MainNavigationDetailLocation? - ) : androidx.compose.runtime.saveable.Saver { - override fun SaverScope.save(value: MainNavigationDetailLocation): String { - return Json.encodeToString(value) - } - - override fun restore(value: String): MainNavigationDetailLocation? { - return earlyLocation ?: Json.decodeFromString(value) - } - } - /** * Flag utilized internally to determine whether the given route is displayed at the root * of a task stack (or on top of Empty) @@ -125,6 +111,19 @@ sealed interface MainNavigationDetailLocation : Parcelable, NavKey { } } + /** + * Subscreens that can be displayed within the stories tab. + */ @Parcelize - sealed class Stories : MainNavigationDetailLocation + sealed class Stories : MainNavigationDetailLocation { + @Transient + @IgnoredOnParcel + override val isContentRoot: Boolean = true + + @Serializable data object Archive : Stories() + + @Serializable data object MyStories : Stories() + + @Serializable data object PrivacySettings : Stories() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt index aa7641035e..3d3d3a3c0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt @@ -20,13 +20,18 @@ import androidx.lifecycle.viewmodel.CreationExtras import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.reactive.asFlow @@ -47,6 +52,7 @@ import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.stories.Stories +import org.thoughtcrime.securesms.stories.StoriesBackStack import org.thoughtcrime.securesms.util.delegate import org.thoughtcrime.securesms.window.AppScaffoldNavigator import java.util.Optional @@ -90,19 +96,21 @@ class MainNavigationViewModel( val callsBackStackEntries: SnapshotStateList get() = callsBackStack.entries + private val storiesBackStack: StoriesBackStack = StoriesBackStack(savedStateHandle) + val storiesBackStackEntries: SnapshotStateList + get() = storiesBackStack.entries + private val currentTabBackStack: MainDetailBackStack? get() { val currentListLocation = internalMainNavigationState.value.currentListLocation return when { currentListLocation.isChatsTab -> chatsBackStack currentListLocation == MainNavigationListLocation.CALLS -> callsBackStack + currentListLocation == MainNavigationListLocation.STORIES -> storiesBackStack else -> null } } - private val internalDetailLocation = MutableSharedFlow() - val detailLocation: SharedFlow = internalDetailLocation - private val internalIsFullScreenPane = MutableStateFlow(false) val isFullScreenPane: StateFlow = internalIsFullScreenPane @@ -127,6 +135,25 @@ class MainNavigationViewModel( private val internalMainNavigationState = MutableStateFlow(MainNavigationState(currentListLocation = initialListLocation)) val mainNavigationState: StateFlow = internalMainNavigationState + @OptIn(ExperimentalCoroutinesApi::class) + val detailLocation: StateFlow = mainNavigationState.flatMapLatest { state -> + when { + state.currentListLocation.isChatsTab -> { + snapshotFlow { chatsBackStack.entries.lastOrNull() ?: MainNavigationDetailLocation.Empty } + } + + state.currentListLocation == MainNavigationListLocation.CALLS -> { + snapshotFlow { callsBackStack.entries.lastOrNull() ?: MainNavigationDetailLocation.Empty } + } + + state.currentListLocation == MainNavigationListLocation.STORIES -> { + snapshotFlow { storiesBackStack.entries.lastOrNull() ?: MainNavigationDetailLocation.Empty } + } + + else -> flowOf(MainNavigationDetailLocation.Empty) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, MainNavigationDetailLocation.Empty) + /** * This is Rx because these are still accessed from Java. */ @@ -134,8 +161,6 @@ class MainNavigationViewModel( val tabClickEventsObservable: Observable = internalTabClickEvents.asObservable() private var earlyNavigationListLocationRequested: MainNavigationListLocation? = null - var earlyNavigationDetailLocationRequested: MainNavigationDetailLocation? = null - private set private val internalPaneFocusRequests = MutableSharedFlow() val paneFocusRequests: SharedFlow = internalPaneFocusRequests @@ -212,17 +237,9 @@ class MainNavigationViewModel( setFocusedPane(role) } - earlyNavigationDetailLocationRequested?.let { - lockPaneToSecondary = false - } - return this.navigator!! } - fun clearEarlyDetailLocation() { - earlyNavigationDetailLocationRequested = null - } - fun setFocusedPane(role: ThreePaneScaffoldRole) { val roleToGoTo = if (lockPaneToSecondary) { ThreePaneScaffoldRole.Secondary @@ -286,21 +303,12 @@ class MainNavigationViewModel( when (location) { is MainNavigationDetailLocation.Empty if currentListLocation.isChatsTab -> clearDetailLocation(chatsBackStack) is MainNavigationDetailLocation.Empty if currentListLocation == MainNavigationListLocation.CALLS -> clearDetailLocation(callsBackStack) + is MainNavigationDetailLocation.Empty if currentListLocation == MainNavigationListLocation.STORIES -> clearDetailLocation(storiesBackStack) is MainNavigationDetailLocation.Chats -> pushChatsDetailLocation(location) is MainNavigationDetailLocation.Conversation -> goToConversation(location) is MainNavigationDetailLocation.Calls, is MainNavigationDetailLocation.CallLinkDetails -> pushCallsDetailLocation(location) - - is MainNavigationDetailLocation.Empty, - is MainNavigationDetailLocation.Stories -> { - if (navigator == null) { - earlyNavigationDetailLocationRequested = location - return - } - - viewModelScope.launch { - internalDetailLocation.emit(location) - } - } + is MainNavigationDetailLocation.Stories -> pushStoriesDetailLocation(location) + is MainNavigationDetailLocation.Empty -> Unit } } @@ -340,6 +348,13 @@ class MainNavigationViewModel( fun popCallsDetailLocation() = popDetailLocation(callsBackStack) + private fun pushStoriesDetailLocation(location: MainNavigationDetailLocation) { + storiesBackStack.push(location) + setFocusedPane(ThreePaneScaffoldRole.Primary) + } + + fun popStoriesDetailLocation() = popDetailLocation(storiesBackStack) + private fun popDetailLocation(backStack: MainDetailBackStack) { backStack.pop() diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/StoriesNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/main/StoriesNavHost.kt deleted file mode 100644 index e9f2789caf..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/main/StoriesNavHost.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.main - -import androidx.compose.ui.platform.LocalResources -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import org.signal.core.ui.rememberIsSplitPane - -fun NavGraphBuilder.storiesNavGraphBuilder() { - composable { - if (LocalResources.current.rememberIsSplitPane()) { - EmptyDetailScreen() - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoriesBackStack.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoriesBackStack.kt new file mode 100644 index 0000000000..d9eae7264b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoriesBackStack.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.stories + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi +import androidx.lifecycle.viewmodel.compose.saveable +import org.thoughtcrime.securesms.main.MainDetailBackStack +import org.thoughtcrime.securesms.main.MainNavigationDetailLocation + +/** + * Controls the navigation stack used by the stories screen. + */ +@OptIn(SavedStateHandleSaveableApi::class) +class StoriesBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack { + + companion object { + private const val KEY = "stories_back_stack" + + val saver: Saver, ArrayList> = Saver( + save = { ArrayList(it) }, + restore = { mutableStateListOf(*it.toTypedArray()) } + ) + } + + override val entries: SnapshotStateList = savedStateHandle.saveable( + key = KEY, + saver = saver + ) { + mutableStateListOf(MainNavigationDetailLocation.Empty) + } + + override fun push(location: MainNavigationDetailLocation) { + when { + location is MainNavigationDetailLocation.Empty || location == entries.lastOrNull() -> Unit + + location.isContentRoot -> { + entries.removeAll { it !is MainNavigationDetailLocation.Empty } + entries.add(location) + } + + else -> entries.add(location) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoriesNavigation.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoriesNavigation.kt new file mode 100644 index 0000000000..2757f1203d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoriesNavigation.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.stories + +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.fragment.compose.AndroidFragment +import androidx.fragment.compose.rememberFragmentState +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import org.signal.core.ui.navigation.TransitionSpecs +import org.thoughtcrime.securesms.MainNavigator +import org.thoughtcrime.securesms.compose.FragmentBackHandler +import org.thoughtcrime.securesms.compose.FragmentBackPressedState +import org.thoughtcrime.securesms.main.EmptyDetailScreen +import org.thoughtcrime.securesms.main.MainNavigationDetailLocation +import org.thoughtcrime.securesms.stories.archive.StoryArchiveScreen +import org.thoughtcrime.securesms.stories.my.MyStoriesFragment +import org.thoughtcrime.securesms.stories.settings.StorySettingsNavHostFragment + +fun EntryProviderScope.storiesNavEntries() { + entry { + NoStorySelectedEntry() + } + + entry( + metadata = TransitionSpecs.None.metadata + ) { + StoryArchiveEntry() + } + + entry( + metadata = TransitionSpecs.None.metadata + ) { + MyStoriesEntry() + } + + entry( + metadata = TransitionSpecs.None.metadata + ) { + StoryPrivacySettingsEntry() + } +} + +@Composable +private fun NoStorySelectedEntry() { + EmptyDetailScreen() +} + +@Composable +private fun StoryArchiveEntry() { + val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current + informNavigatorWeAreReady() + + StoryArchiveScreen( + onNavigationClick = { backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed() } + ) +} + +@Composable +private fun MyStoriesEntry() { + val fragmentState = key(MainNavigationDetailLocation.Stories.MyStories) { rememberFragmentState() } + val backPressedState = remember { FragmentBackPressedState() } + FragmentBackHandler(backPressedState) + + informNavigatorWeAreReady() + + AndroidFragment( + clazz = MyStoriesFragment::class.java, + fragmentState = fragmentState, + modifier = androidx.compose.ui.Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding() + .navigationBarsPadding() + ) { fragment -> + backPressedState.attach(fragment) + } +} + +@Composable +private fun StoryPrivacySettingsEntry() { + val fragmentState = key(MainNavigationDetailLocation.Stories.PrivacySettings) { rememberFragmentState() } + val backPressedState = remember { FragmentBackPressedState() } + FragmentBackHandler(backPressedState) + + informNavigatorWeAreReady() + + AndroidFragment( + clazz = StorySettingsNavHostFragment::class.java, + fragmentState = fragmentState, + modifier = androidx.compose.ui.Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding() + .navigationBarsPadding() + ) { fragment -> + backPressedState.attach(fragment) + } +} + +@Composable +private fun informNavigatorWeAreReady() { + val navigator = LocalActivity.current as? MainNavigator.NavigatorProvider + LaunchedEffect(navigator) { + navigator?.onFirstRender() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt index f51243601a..56ac7943f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.main.MainNavigationDetailLocation import org.thoughtcrime.securesms.main.MainNavigationListLocation import org.thoughtcrime.securesms.main.MainNavigationViewModel import org.thoughtcrime.securesms.main.MainSnackbarHostKey @@ -44,7 +45,6 @@ import org.thoughtcrime.securesms.stories.StoryTextPostModel import org.thoughtcrime.securesms.stories.StoryViewerArgs import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs -import org.thoughtcrime.securesms.stories.my.MyStoriesActivity import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter @@ -154,7 +154,11 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { if (!closeSearchIfOpen()) { - mainNavigationViewModel.onChatsSelected() + if (mainNavigationViewModel.storiesBackStackEntries.last() != MainNavigationDetailLocation.Empty) { + mainNavigationViewModel.popStoriesDetailLocation() + } else { + mainNavigationViewModel.onChatsSelected() + } } } } @@ -284,7 +288,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l private fun openStoryViewer(model: StoriesLandingItem.Model, preview: View, isFromInfoContextMenuAction: Boolean) { if (model.data.storyRecipient.isMyStory) { - startActivityIfAble(Intent(requireContext(), MyStoriesActivity::class.java)) + mainNavigationViewModel.goTo(MainNavigationDetailLocation.Stories.MyStories) } else if (model.data.primaryStory.messageRecord.isOutgoing && model.data.primaryStory.messageRecord.isFailed) { if (model.data.primaryStory.messageRecord.isIdentityMismatchFailure) { SafetyNumberBottomSheet diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt index dfc971e0d1..ba7d278d45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt @@ -5,6 +5,7 @@ import android.view.View import androidx.activity.OnBackPressedCallback import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat +import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch @@ -18,6 +19,7 @@ import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.main.MainNavigationViewModel import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.stories.StoryTextPostModel @@ -35,6 +37,8 @@ class MyStoriesFragment : DSLSettingsFragment( private val lifecycleDisposable = LifecycleDisposable() + private val mainNavigationViewModel: MainNavigationViewModel by activityViewModels() + private val viewModel: MyStoriesViewModel by viewModels( factoryProducer = { MyStoriesViewModel.Factory(MyStoriesRepository(requireContext())) @@ -48,7 +52,7 @@ class MyStoriesFragment : DSLSettingsFragment( viewLifecycleOwner, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - requireActivity().finish() + mainNavigationViewModel.popStoriesDetailLocation() } } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/StorySettingsNavHostFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/StorySettingsNavHostFragment.kt new file mode 100644 index 0000000000..d8a83979b1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/StorySettingsNavHostFragment.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.stories.settings + +import android.os.Bundle +import androidx.core.os.bundleOf +import androidx.navigation.fragment.NavHostFragment +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.FragmentBackPressedInfo +import org.thoughtcrime.securesms.compose.FragmentBackPressedInfoProvider + +/** + * Hosts the story privacy settings nav2 graph within a nav3 detail pane entry. + */ +class StorySettingsNavHostFragment : NavHostFragment(), FragmentBackPressedInfoProvider { + + override fun onCreate(savedInstanceState: Bundle?) { + navController.setGraph( + R.navigation.story_privacy_settings, + bundleOf("title_id" to R.string.StoriesPrivacySettingsFragment__story_privacy) + ) + super.onCreate(savedInstanceState) + } + + override fun getFragmentBackPressedInfo(): Flow { + return navController.currentBackStackEntryFlow.map { + if (navController.previousBackStackEntry != null) { + FragmentBackPressedInfo.Enabled { navController.popBackStack() } + } else { + FragmentBackPressedInfo.Disabled + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt index 37f2d2e894..f43793c0ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt @@ -61,12 +61,6 @@ class StoriesPrivacySettingsFragment : val (top, middle, bottom) = adapter.adapters - findNavController().addOnDestinationChangedListener { _, destination, _ -> - if (destination.id == R.id.storiesPrivacySettingsFragment) { - viewModel.pagingController.onDataInvalidated() - } - } - @Suppress("UNCHECKED_CAST") ContactSearchModels.registerStoryItems( mappingAdapter = middle as PagingMappingAdapter, @@ -105,6 +99,11 @@ class StoriesPrivacySettingsFragment : } } + override fun onResume() { + super.onResume() + viewModel.pagingController.onDataInvalidated() + } + private fun getTopConfiguration(state: StoriesPrivacySettingsState): DSLConfiguration { return configure { if (state.areStoriesEnabled) {