Migrate stories tab to nav 3.

This commit is contained in:
Jeffrey Starke
2026-07-07 17:10:23 -04:00
committed by GitHub
parent 566a72ecf7
commit 4ab4bb458a
13 changed files with 344 additions and 302 deletions
@@ -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}"
@@ -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() {
@@ -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)
)
}
}
@@ -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()
)
}
@@ -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<MainNavigationDetailLocation, String> {
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()
}
}
@@ -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<MainNavigationDetailLocation>
get() = callsBackStack.entries
private val storiesBackStack: StoriesBackStack = StoriesBackStack(savedStateHandle)
val storiesBackStackEntries: SnapshotStateList<MainNavigationDetailLocation>
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<MainNavigationDetailLocation>()
val detailLocation: SharedFlow<MainNavigationDetailLocation> = internalDetailLocation
private val internalIsFullScreenPane = MutableStateFlow(false)
val isFullScreenPane: StateFlow<Boolean> = internalIsFullScreenPane
@@ -127,6 +135,25 @@ class MainNavigationViewModel(
private val internalMainNavigationState = MutableStateFlow(MainNavigationState(currentListLocation = initialListLocation))
val mainNavigationState: StateFlow<MainNavigationState> = internalMainNavigationState
@OptIn(ExperimentalCoroutinesApi::class)
val detailLocation: StateFlow<MainNavigationDetailLocation> = 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<MainNavigationListLocation> = internalTabClickEvents.asObservable()
private var earlyNavigationListLocationRequested: MainNavigationListLocation? = null
var earlyNavigationDetailLocationRequested: MainNavigationDetailLocation? = null
private set
private val internalPaneFocusRequests = MutableSharedFlow<ThreePaneScaffoldRole?>()
val paneFocusRequests: SharedFlow<ThreePaneScaffoldRole?> = 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()
@@ -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<MainNavigationDetailLocation.Empty> {
if (LocalResources.current.rememberIsSplitPane()) {
EmptyDetailScreen()
}
}
}
@@ -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<SnapshotStateList<MainNavigationDetailLocation>, ArrayList<MainNavigationDetailLocation>> = Saver(
save = { ArrayList(it) },
restore = { mutableStateListOf(*it.toTypedArray()) }
)
}
override val entries: SnapshotStateList<MainNavigationDetailLocation> = 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)
}
}
}
@@ -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<NavKey>.storiesNavEntries() {
entry<MainNavigationDetailLocation.Empty> {
NoStorySelectedEntry()
}
entry<MainNavigationDetailLocation.Stories.Archive>(
metadata = TransitionSpecs.None.metadata
) {
StoryArchiveEntry()
}
entry<MainNavigationDetailLocation.Stories.MyStories>(
metadata = TransitionSpecs.None.metadata
) {
MyStoriesEntry()
}
entry<MainNavigationDetailLocation.Stories.PrivacySettings>(
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()
}
}
@@ -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
@@ -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()
}
}
)
@@ -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<FragmentBackPressedInfo> {
return navController.currentBackStackEntryFlow.map {
if (navController.previousBackStackEntry != null) {
FragmentBackPressedInfo.Enabled { navController.popBackStack() }
} else {
FragmentBackPressedInfo.Disabled
}
}
}
}
@@ -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<ContactSearchKey>,
@@ -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) {