mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-21 04:54:41 +01:00
Migrate stories tab to nav 3.
This commit is contained in:
+8
-6
@@ -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()
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+39
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user