mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-06 22:15:43 +01:00
Migrate chats tab to nav 3.
- resolves signalapp/Signal-Android#8947 - resolves signalapp/Signal-Android#14777 - resolves signalapp/Signal-Android#14784 - resolves signalapp/Signal-Android#14800 - resolves signalapp/Signal-Android#14803
This commit is contained in:
+5
-3
@@ -348,11 +348,13 @@ class MainNavigationLaunchTest {
|
||||
await(description = "no new ConversationFragment after Empty detail intent") {
|
||||
recorder.createdArgs.size == baseline
|
||||
}
|
||||
// The user-visible signal that we're "back on the list" is the chat list fragment
|
||||
// being attached, not just the VM saying CHATS.
|
||||
awaitListFragment(launched, MainNavigationListLocation.CHATS)
|
||||
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
|
||||
await(description = "conversation cleared from chats back stack after Empty detail intent") {
|
||||
vm.chatsBackStackEntries.none { it is MainNavigationDetailLocation.Conversation }
|
||||
}
|
||||
|
||||
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
|
||||
"Expected CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
@@ -83,6 +85,7 @@ import kotlinx.coroutines.withContext
|
||||
import org.signal.core.ui.BottomSheetUtil
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.ui.navigation.TransitionSpecs
|
||||
import org.signal.core.ui.permissions.Permissions
|
||||
import org.signal.core.ui.rememberIsSplitPane
|
||||
import org.signal.core.util.AppForegroundObserver
|
||||
@@ -103,6 +106,8 @@ import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
import org.thoughtcrime.securesms.calls.quality.CallQuality
|
||||
import org.thoughtcrime.securesms.calls.quality.CallQualityBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.chats.ConversationTransitionState
|
||||
import org.thoughtcrime.securesms.chats.chatsNavEntries
|
||||
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment
|
||||
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
|
||||
import org.thoughtcrime.securesms.components.compose.ConnectivityWarningBottomSheet
|
||||
@@ -132,7 +137,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.ChatNavGraphState
|
||||
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
|
||||
import org.thoughtcrime.securesms.main.MainBottomChrome
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
|
||||
@@ -141,7 +145,6 @@ import org.thoughtcrime.securesms.main.MainContentLayoutData
|
||||
import org.thoughtcrime.securesms.main.MainMegaphoneState
|
||||
import org.thoughtcrime.securesms.main.MainNavigationBar
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocationEffect
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRail
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRouter
|
||||
@@ -155,7 +158,6 @@ import org.thoughtcrime.securesms.main.MainToolbarState
|
||||
import org.thoughtcrime.securesms.main.MainToolbarViewModel
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.main.callNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.chatNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.navigateToDetailLocation
|
||||
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
|
||||
import org.thoughtcrime.securesms.main.rememberFocusRequester
|
||||
@@ -494,18 +496,15 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
|
||||
val chatNavGraphState = ChatNavGraphState.remember(isSplitPane)
|
||||
val convoTransitionState = ConversationTransitionState.remember(isSplitPane)
|
||||
val mutableInteractionSource = remember { MutableInteractionSource() }
|
||||
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
|
||||
|
||||
val chatsNavHostController = rememberDetailNavHostController(
|
||||
onRequestFocus = rememberFocusRequester(
|
||||
mainNavigationViewModel = mainNavigationViewModel,
|
||||
currentListLocation = mainNavigationState.currentListLocation,
|
||||
isTargetListLocation = { it in listOf(MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE) }
|
||||
)
|
||||
) {
|
||||
chatNavGraphBuilder(chatNavGraphState)
|
||||
LaunchedEffect(convoTransitionState) {
|
||||
mainNavigationViewModel.setChatListSnapshotCaptureProvider { convoTransitionState.writeGraphicsLayerToBitmap() }
|
||||
}
|
||||
|
||||
LaunchedEffect(isSplitPane) {
|
||||
mainNavigationViewModel.onSplitPaneChanged(isSplitPane)
|
||||
}
|
||||
|
||||
val callsNavHostController = rememberDetailNavHostController(
|
||||
@@ -527,22 +526,23 @@ class MainActivity :
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
suspend fun navigateToLocation(location: MainNavigationDetailLocation) {
|
||||
fun navigateToLocation(location: MainNavigationDetailLocation) {
|
||||
when (location) {
|
||||
is MainNavigationDetailLocation.Empty -> {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
|
||||
throw IllegalStateException("Navigation to ${mainNavigationState.currentListLocation} should be handled by ChatsBackStack.")
|
||||
}
|
||||
|
||||
MainNavigationListLocation.CALLS -> callsNavHostController
|
||||
MainNavigationListLocation.STORIES -> storiesNavHostController
|
||||
}.navigateToDetailLocation(location)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Conversation -> {
|
||||
chatNavGraphState.writeGraphicsLayerToBitmap()
|
||||
chatsNavHostController.navigateToDetailLocation(location)
|
||||
is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> {
|
||||
throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.")
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(location)
|
||||
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
|
||||
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
|
||||
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
|
||||
@@ -556,6 +556,7 @@ class MainActivity :
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) {
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Empty)
|
||||
scope.launch {
|
||||
@@ -606,6 +607,14 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(wrappedNavigator.scaffoldValue.primary) {
|
||||
if (wrappedNavigator.scaffoldValue.primary == PaneAdaptedValue.Hidden &&
|
||||
mainNavigationState.currentListLocation.isChatsTab
|
||||
) {
|
||||
mainNavigationViewModel.onChatsDetailPaneCollapsed()
|
||||
}
|
||||
}
|
||||
|
||||
val noEnterTransitionFactory = remember {
|
||||
AppScaffoldAnimationStateFactory(
|
||||
enabledStates = AppScaffoldNavigator.NavigationState.entries.filterNot {
|
||||
@@ -616,7 +625,7 @@ class MainActivity :
|
||||
|
||||
AppScaffold(
|
||||
navigator = wrappedNavigator,
|
||||
modifier = chatNavGraphState.writeContentToGraphicsLayer(),
|
||||
modifier = convoTransitionState.writeContentToGraphicsLayer(),
|
||||
paneExpansionState = paneExpansionState,
|
||||
contentWindowInsets = WindowInsets(),
|
||||
snackbarHost = {
|
||||
@@ -727,10 +736,16 @@ class MainActivity :
|
||||
primaryContent = {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
|
||||
DetailsScreenNavHost(
|
||||
navHostController = chatsNavHostController,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
if (mainNavigationViewModel.chatsBackStackEntries.isNotEmpty()) {
|
||||
NavDisplay(
|
||||
backStack = mainNavigationViewModel.chatsBackStackEntries,
|
||||
onBack = { mainNavigationViewModel.popChatsDetailLocation() },
|
||||
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
|
||||
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
|
||||
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
|
||||
entryProvider = entryProvider { chatsNavEntries(convoTransitionState) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MainNavigationListLocation.CALLS -> {
|
||||
@@ -758,7 +773,7 @@ class MainActivity :
|
||||
} else {
|
||||
null
|
||||
},
|
||||
animatorFactory = if (mainNavigationState.currentListLocation == MainNavigationListLocation.CHATS || mainNavigationState.currentListLocation == MainNavigationListLocation.ARCHIVE) {
|
||||
animatorFactory = if (mainNavigationState.currentListLocation.isChatsTab) {
|
||||
noEnterTransitionFactory
|
||||
} else {
|
||||
AppScaffoldAnimationStateFactory.Default
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.chats
|
||||
|
||||
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.MainNavigationDetailLocation
|
||||
|
||||
/**
|
||||
* Controls the navigation stack used by the chats screen.
|
||||
*/
|
||||
@OptIn(SavedStateHandleSaveableApi::class)
|
||||
class ChatsBackStack(savedStateHandle: SavedStateHandle) {
|
||||
|
||||
companion object {
|
||||
private const val KEY = "chats_back_stack"
|
||||
|
||||
val saver: Saver<SnapshotStateList<MainNavigationDetailLocation>, ArrayList<MainNavigationDetailLocation>> = Saver(
|
||||
save = { ArrayList(it) },
|
||||
restore = { mutableStateListOf(*it.toTypedArray()) }
|
||||
)
|
||||
}
|
||||
|
||||
val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
|
||||
key = KEY,
|
||||
saver = saver
|
||||
) {
|
||||
mutableStateListOf()
|
||||
}
|
||||
|
||||
val activeConversationThreadId: Long?
|
||||
get() = entries
|
||||
.filterIsInstance<MainNavigationDetailLocation.Conversation>()
|
||||
.lastOrNull()
|
||||
?.controllerKey
|
||||
|
||||
val hasConversation: Boolean
|
||||
get() = entries.any { it is MainNavigationDetailLocation.Conversation }
|
||||
|
||||
/**
|
||||
* Pushes an entry onto the stack.
|
||||
*/
|
||||
fun push(location: MainNavigationDetailLocation) {
|
||||
when (location) {
|
||||
is MainNavigationDetailLocation.Empty, entries.lastOrNull() -> Unit
|
||||
|
||||
is MainNavigationDetailLocation.Conversation -> {
|
||||
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
|
||||
entries.add(location)
|
||||
}
|
||||
|
||||
else -> entries.add(location)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pops the top entry off the stack. Returns true if something was popped, false if the stack is already at its root.
|
||||
*/
|
||||
fun pop(): Boolean {
|
||||
if (entries.size <= 1) return false
|
||||
entries.removeAt(entries.lastIndex)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the stack to its base empty state.
|
||||
*/
|
||||
fun reset(isSplitPane: Boolean) {
|
||||
entries.clear()
|
||||
if (isSplitPane) {
|
||||
entries.add(MainNavigationDetailLocation.Empty)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that [MainNavigationDetailLocation.Empty] is present in the stack iff isSplitPane=true.
|
||||
*/
|
||||
fun updateEmptyDetailForPaneMode(isSplitPane: Boolean) {
|
||||
val hasEmptyBase = entries.firstOrNull() is MainNavigationDetailLocation.Empty
|
||||
when {
|
||||
isSplitPane && !hasEmptyBase -> entries.add(0, MainNavigationDetailLocation.Empty)
|
||||
!isSplitPane && hasEmptyBase -> entries.removeAll { it is MainNavigationDetailLocation.Empty }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.chats
|
||||
|
||||
import android.os.Bundle
|
||||
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.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.fragment.compose.AndroidFragment
|
||||
import androidx.fragment.compose.rememberFragmentState
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.navigation.TransitionSpecs
|
||||
import org.thoughtcrime.securesms.MainNavigator
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment
|
||||
import org.thoughtcrime.securesms.compose.FragmentBackHandler
|
||||
import org.thoughtcrime.securesms.compose.FragmentBackPressedState
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
|
||||
import org.thoughtcrime.securesms.main.EmptyDetailScreen
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
|
||||
|
||||
fun EntryProviderScope<NavKey>.chatsNavEntries(
|
||||
transitionState: ConversationTransitionState
|
||||
) {
|
||||
entry<MainNavigationDetailLocation.Empty> {
|
||||
NoConvoSelectedEntry()
|
||||
}
|
||||
|
||||
entry<MainNavigationDetailLocation.Conversation>(
|
||||
// disable slide animation - it's unnecessary in split pane mode and is handled by ConversationLoadingMask for single pane mode.
|
||||
metadata = TransitionSpecs.None.metadata
|
||||
) { route ->
|
||||
ConversationEntry(route, transitionState)
|
||||
}
|
||||
|
||||
entry<MainNavigationDetailLocation.Chats.MessageDetails> { route ->
|
||||
MessageDetailsEntry(route)
|
||||
}
|
||||
|
||||
entry<MainNavigationDetailLocation.Chats.ConversationSettings> { route ->
|
||||
ConversationSettingsEntry(route)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoConvoSelectedEntry() {
|
||||
EmptyDetailScreen()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConversationEntry(
|
||||
route: MainNavigationDetailLocation.Conversation,
|
||||
transitionState: ConversationTransitionState
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val navigatorProvider = context as? MainNavigator.NavigatorProvider
|
||||
val fragmentState = key(route) { rememberFragmentState() }
|
||||
val arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) {
|
||||
"Handed null Conversation intent arguments."
|
||||
}
|
||||
|
||||
val fragmentContentReady = remember { MutableStateFlow(false) }
|
||||
val backPressedState = remember { FragmentBackPressedState() }
|
||||
FragmentBackHandler(backPressedState)
|
||||
|
||||
ConversationLoadingMask(
|
||||
transitionState = transitionState,
|
||||
contentReady = fragmentContentReady,
|
||||
onFirstRender = { navigatorProvider?.onFirstRender() }
|
||||
) { modifier ->
|
||||
AndroidFragment(
|
||||
clazz = ConversationFragment::class.java,
|
||||
fragmentState = fragmentState,
|
||||
arguments = arguments,
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.fillMaxSize()
|
||||
) { fragment ->
|
||||
backPressedState.attach(fragment)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
fragment.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
fragment.didFirstFrameRender.collectLatest { fragmentContentReady.value = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageDetailsEntry(route: MainNavigationDetailLocation.Chats.MessageDetails) {
|
||||
val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider
|
||||
val fragmentState = key(route) { rememberFragmentState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
navigatorProvider?.onFirstRender()
|
||||
}
|
||||
|
||||
AndroidFragment(
|
||||
clazz = MessageDetailsFragment::class.java,
|
||||
fragmentState = fragmentState,
|
||||
arguments = MessageDetailsFragment.args(route.recipientId, route.messageId),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConversationSettingsEntry(route: MainNavigationDetailLocation.Chats.ConversationSettings) {
|
||||
val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider
|
||||
val fragmentState = key(route) { rememberFragmentState() }
|
||||
val arguments: Bundle? by produceState(null, route.recipientId) {
|
||||
value = ConversationSettingsNavHostFragment.createArgs(route.recipientId)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
navigatorProvider?.onFirstRender()
|
||||
}
|
||||
|
||||
arguments?.let { args ->
|
||||
val backPressedState = remember { FragmentBackPressedState() }
|
||||
FragmentBackHandler(backPressedState)
|
||||
|
||||
AndroidFragment(
|
||||
clazz = ConversationSettingsNavHostFragment::class.java,
|
||||
fragmentState = fragmentState,
|
||||
arguments = args,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
) { fragment ->
|
||||
backPressedState.attach(fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.chats
|
||||
|
||||
import androidx.compose.animation.core.Transition
|
||||
import androidx.compose.animation.core.animateDp
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldAnimationState
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Wraps [content] with an animation that crossfades a snapshotted chat list bitmap over the conversation fragment while it loads its first frame.
|
||||
*
|
||||
* @param contentReady emits when the fragment's first frame has been rendered.
|
||||
* @param onFirstRender signals that this composable has content ready to display, so the parent activity can proceed with its first draw.
|
||||
* @param content will be animated in as the overlay fades out.
|
||||
*/
|
||||
@Composable
|
||||
fun ConversationLoadingMask(
|
||||
transitionState: ConversationTransitionState,
|
||||
contentReady: StateFlow<Boolean>,
|
||||
onFirstRender: () -> Unit,
|
||||
content: @Composable (chatModifier: Modifier) -> Unit
|
||||
) {
|
||||
// it can take a long time to load content, so we use a "fake" chat list image to delay displaying the fragment
|
||||
// and prevent pop-in. When there's no bitmap (e.g. returning from a sub-route), skip the animation.
|
||||
var shouldDisplayFragment by remember {
|
||||
val hasBitmap = transitionState.chatBitmap != null
|
||||
mutableStateOf(!hasBitmap)
|
||||
}
|
||||
val transition: Transition<Boolean> = updateTransition(shouldDisplayFragment)
|
||||
val bitmap = transitionState.chatBitmap
|
||||
|
||||
val fakeChatListAnimationState = transition.fakeChatListAnimationState()
|
||||
val chatAnimationState = transition.chatAnimationState(hasFake = bitmap != null)
|
||||
|
||||
LaunchedEffect(transition.currentState, transition.isRunning) {
|
||||
if (transition.currentState && !transition.isRunning) {
|
||||
transitionState.clearBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldDisplayFragment) {
|
||||
onFirstRender()
|
||||
}
|
||||
|
||||
LaunchedEffect(contentReady) {
|
||||
if (!shouldDisplayFragment) {
|
||||
withTimeoutOrNull(5.seconds) {
|
||||
contentReady.first { it }
|
||||
}
|
||||
shouldDisplayFragment = true
|
||||
}
|
||||
}
|
||||
|
||||
val chatModifier = Modifier.graphicsLayer {
|
||||
with(chatAnimationState) { applyChildValues() }
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
content(chatModifier)
|
||||
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
with(fakeChatListAnimationState) { applyChildValues() }
|
||||
}
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Transition<Boolean>.fakeChatListAnimationState(): AppScaffoldAnimationState {
|
||||
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f }
|
||||
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp }
|
||||
return remember {
|
||||
AppScaffoldAnimationState(
|
||||
offset = offset,
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Transition<Boolean>.chatAnimationState(hasFake: Boolean): AppScaffoldAnimationState {
|
||||
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 1f else 0f }
|
||||
return if (!hasFake) {
|
||||
remember {
|
||||
AppScaffoldAnimationState(
|
||||
offset = mutableStateOf(0.dp),
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0.dp else 48.dp }
|
||||
remember {
|
||||
AppScaffoldAnimationState(
|
||||
offset = offset,
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.chats
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
|
||||
/**
|
||||
* Allows the setting of a "fake" bitmap driven by a graphics layer to coordinate delayed animations
|
||||
* in lieu of proper support for postponing enter transitions.
|
||||
*/
|
||||
@Stable
|
||||
class ConversationTransitionState private constructor(
|
||||
val isSplitPane: Boolean,
|
||||
val graphicsLayer: GraphicsLayer
|
||||
) {
|
||||
companion object {
|
||||
@Composable
|
||||
fun remember(isSplitPane: Boolean): ConversationTransitionState {
|
||||
val graphicsLayer = rememberGraphicsLayer()
|
||||
|
||||
return remember(isSplitPane) {
|
||||
ConversationTransitionState(isSplitPane, graphicsLayer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var chatBitmap: ImageBitmap? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
private var hasWrittenToGraphicsLayer: Boolean by mutableStateOf(false)
|
||||
|
||||
suspend fun writeGraphicsLayerToBitmap() {
|
||||
// toImageBitmap() uses LayerSnapshot which has format compatibility issues on Android 7 and below
|
||||
if (Build.VERSION.SDK_INT >= 26 && !isSplitPane && hasWrittenToGraphicsLayer) {
|
||||
chatBitmap = graphicsLayer.toImageBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
fun writeContentToGraphicsLayer(): Modifier {
|
||||
if (isSplitPane) return Modifier
|
||||
|
||||
return Modifier.drawWithContent {
|
||||
graphicsLayer.record {
|
||||
this@drawWithContent.drawContent()
|
||||
hasWrittenToGraphicsLayer = true
|
||||
}
|
||||
|
||||
drawLayer(graphicsLayer)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearBitmap() {
|
||||
chatBitmap = null
|
||||
}
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.compose.animation.core.Transition
|
||||
import androidx.compose.animation.core.animateDp
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.Image
|
||||
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.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.compose.AndroidFragment
|
||||
import androidx.fragment.compose.rememberFragmentState
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.rememberIsSplitPane
|
||||
import org.thoughtcrime.securesms.MainNavigator
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment
|
||||
import org.thoughtcrime.securesms.compose.FragmentBackHandler
|
||||
import org.thoughtcrime.securesms.compose.FragmentBackPressedState
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.serialization.JsonSerializableNavType
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldAnimationState
|
||||
import kotlin.reflect.typeOf
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private val conversationArgsType = typeOf<ConversationArgs>()
|
||||
private val recipientIdType = typeOf<RecipientId>()
|
||||
private val messageIdType = typeOf<MessageId>()
|
||||
|
||||
fun NavGraphBuilder.chatNavGraphBuilder(
|
||||
chatNavGraphState: ChatNavGraphState
|
||||
) {
|
||||
composable<MainNavigationDetailLocation.Empty> {
|
||||
if (LocalResources.current.rememberIsSplitPane()) {
|
||||
EmptyDetailScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composable<MainNavigationDetailLocation.Conversation>(
|
||||
typeMap = mapOf(
|
||||
conversationArgsType to JsonSerializableNavType(ConversationArgs.serializer())
|
||||
)
|
||||
) { navBackStackEntry ->
|
||||
val route = navBackStackEntry.toRoute<MainNavigationDetailLocation.Conversation>()
|
||||
val fragmentState = key(route) { rememberFragmentState() }
|
||||
val context = LocalContext.current
|
||||
|
||||
// Because it can take a long time to load content, we use a "fake" chat list image to delay displaying
|
||||
// the fragment and prevent pop-in. When there's no bitmap (e.g. returning from a sub-route), skip the animation.
|
||||
var shouldDisplayFragment by remember { mutableStateOf(chatNavGraphState.chatBitmap == null) }
|
||||
val transition: Transition<Boolean> = updateTransition(shouldDisplayFragment)
|
||||
val bitmap = chatNavGraphState.chatBitmap
|
||||
|
||||
val fakeChatListAnimationState = transition.fakeChatListAnimationState()
|
||||
val chatAnimationState = transition.chatAnimationState(bitmap != null)
|
||||
|
||||
LaunchedEffect(transition.currentState, transition.isRunning) {
|
||||
if (transition.currentState && !transition.isRunning) {
|
||||
chatNavGraphState.clearBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldDisplayFragment) {
|
||||
(context as? MainNavigator.NavigatorProvider)?.onFirstRender()
|
||||
}
|
||||
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
with(fakeChatListAnimationState) {
|
||||
applyChildValues()
|
||||
}
|
||||
}
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
val backPressedState = remember { FragmentBackPressedState() }
|
||||
FragmentBackHandler(backPressedState)
|
||||
|
||||
AndroidFragment(
|
||||
clazz = ConversationFragment::class.java,
|
||||
fragmentState = fragmentState,
|
||||
arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) { "Handed null Conversation intent arguments." },
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
with(chatAnimationState) {
|
||||
applyChildValues()
|
||||
}
|
||||
}
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.fillMaxSize()
|
||||
) { fragment ->
|
||||
backPressedState.attach(fragment)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
fragment.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
fragment.didFirstFrameRender.collectLatest {
|
||||
if (!shouldDisplayFragment) {
|
||||
shouldDisplayFragment = it
|
||||
if (!it) {
|
||||
delay(150.milliseconds)
|
||||
shouldDisplayFragment = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composable<MainNavigationDetailLocation.Chats.MessageDetails>(
|
||||
typeMap = mapOf(
|
||||
recipientIdType to JsonSerializableNavType(RecipientId.serializer()),
|
||||
messageIdType to MessageId.NavType()
|
||||
)
|
||||
) { navBackStackEntry ->
|
||||
val context = LocalContext.current
|
||||
val route = navBackStackEntry.toRoute<MainNavigationDetailLocation.Chats.MessageDetails>()
|
||||
val fragmentState = key(route) { rememberFragmentState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
(context as? MainNavigator.NavigatorProvider)?.onFirstRender()
|
||||
}
|
||||
|
||||
AndroidFragment(
|
||||
clazz = MessageDetailsFragment::class.java,
|
||||
fragmentState = fragmentState,
|
||||
arguments = MessageDetailsFragment.args(route.recipientId, route.messageId),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
)
|
||||
}
|
||||
|
||||
composable<MainNavigationDetailLocation.Chats.ConversationSettings>(
|
||||
typeMap = mapOf(
|
||||
recipientIdType to JsonSerializableNavType(RecipientId.serializer())
|
||||
)
|
||||
) { navBackStackEntry ->
|
||||
|
||||
val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider
|
||||
val route = navBackStackEntry.toRoute<MainNavigationDetailLocation.Chats.ConversationSettings>()
|
||||
val fragmentState = key(route) { rememberFragmentState() }
|
||||
val arguments: Bundle? by produceState(null, route.recipientId) {
|
||||
value = ConversationSettingsNavHostFragment.createArgs(route.recipientId)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
navigatorProvider?.onFirstRender()
|
||||
}
|
||||
|
||||
arguments?.let { args ->
|
||||
val backPressedState = remember { FragmentBackPressedState() }
|
||||
FragmentBackHandler(backPressedState)
|
||||
|
||||
AndroidFragment(
|
||||
clazz = ConversationSettingsNavHostFragment::class.java,
|
||||
fragmentState = fragmentState,
|
||||
arguments = args,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
) { fragment ->
|
||||
backPressedState.attach(fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Transition<Boolean>.fakeChatListAnimationState(): AppScaffoldAnimationState {
|
||||
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f }
|
||||
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp }
|
||||
|
||||
return remember {
|
||||
AppScaffoldAnimationState(
|
||||
offset = offset,
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Transition<Boolean>.chatAnimationState(hasFake: Boolean): AppScaffoldAnimationState {
|
||||
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 1f else 0f }
|
||||
|
||||
return if (!hasFake) {
|
||||
remember {
|
||||
AppScaffoldAnimationState(
|
||||
offset = mutableStateOf(0.dp),
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0.dp else 48.dp }
|
||||
|
||||
remember {
|
||||
AppScaffoldAnimationState(
|
||||
offset = offset,
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows the setting of a "fake" bitmap driven by a graphics layer to coordinate delayed animations
|
||||
* in lieu of proper support for postponing enter transitions.
|
||||
*/
|
||||
@Stable
|
||||
class ChatNavGraphState private constructor(
|
||||
val isSplitPane: Boolean,
|
||||
val graphicsLayer: GraphicsLayer
|
||||
) {
|
||||
companion object {
|
||||
@Composable
|
||||
fun remember(isSplitPane: Boolean): ChatNavGraphState {
|
||||
val graphicsLayer = rememberGraphicsLayer()
|
||||
|
||||
return remember(isSplitPane) {
|
||||
ChatNavGraphState(
|
||||
isSplitPane,
|
||||
graphicsLayer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var chatBitmap: ImageBitmap? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
private var hasWrittenToGraphicsLayer: Boolean by mutableStateOf(false)
|
||||
|
||||
suspend fun writeGraphicsLayerToBitmap() {
|
||||
// toImageBitmap() uses LayerSnapshot which has format compatibility issues on Android 7 and below
|
||||
if (Build.VERSION.SDK_INT >= 26 && !isSplitPane && hasWrittenToGraphicsLayer) {
|
||||
chatBitmap = graphicsLayer.toImageBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
fun writeContentToGraphicsLayer(): Modifier {
|
||||
return Modifier.drawWithContent {
|
||||
graphicsLayer.record {
|
||||
this@drawWithContent.drawContent()
|
||||
hasWrittenToGraphicsLayer = true
|
||||
}
|
||||
|
||||
drawLayer(graphicsLayer)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearBitmap() {
|
||||
chatBitmap = null
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,10 @@ enum class MainNavigationListLocation(
|
||||
STORIES(
|
||||
label = R.string.ConversationListTabs__stories,
|
||||
icon = R.raw.stories_28
|
||||
)
|
||||
);
|
||||
|
||||
val isChatsTab: Boolean
|
||||
get() = this == CHATS || this == ARCHIVE
|
||||
}
|
||||
|
||||
data class MainNavigationState(
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -23,7 +24,7 @@ import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
*/
|
||||
@Serializable
|
||||
@Parcelize
|
||||
sealed interface MainNavigationDetailLocation : Parcelable {
|
||||
sealed interface MainNavigationDetailLocation : Parcelable, NavKey {
|
||||
|
||||
class Saver(
|
||||
val earlyLocation: MainNavigationDetailLocation?
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
|
||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
|
||||
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
@@ -34,6 +37,7 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogRow
|
||||
import org.thoughtcrime.securesms.chats.ChatsBackStack
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarStateConsumerRegistry
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
@@ -46,6 +50,7 @@ import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.delegate
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
|
||||
import java.util.Optional
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
class MainNavigationViewModel(
|
||||
@@ -74,16 +79,23 @@ class MainNavigationViewModel(
|
||||
private var navigator: AppScaffoldNavigator<Any>? = null
|
||||
private var navigatorScope: CoroutineScope? = null
|
||||
|
||||
private var captureChatListSnapshot: (suspend () -> Unit)? = null
|
||||
private var isSplitPane: Boolean = false
|
||||
|
||||
private val chatsBackStack: ChatsBackStack = ChatsBackStack(savedStateHandle)
|
||||
val chatsBackStackEntries: SnapshotStateList<MainNavigationDetailLocation>
|
||||
get() = chatsBackStack.entries
|
||||
|
||||
private val internalDetailLocation = MutableSharedFlow<MainNavigationDetailLocation>()
|
||||
val detailLocation: SharedFlow<MainNavigationDetailLocation> = internalDetailLocation
|
||||
|
||||
private val internalIsFullScreenPane = MutableStateFlow(false)
|
||||
val isFullScreenPane: StateFlow<Boolean> = internalIsFullScreenPane
|
||||
|
||||
private val internalActiveChatThreadId = MutableStateFlow(-1L)
|
||||
val observableActiveChatThreadId: Observable<Long> = internalActiveChatThreadId.combine(isFullScreenPane) { id, expanded ->
|
||||
if (expanded) -1L else id
|
||||
}.asObservable()
|
||||
val observableActiveChatThreadId: Observable<Long> =
|
||||
snapshotFlow { chatsBackStack.activeConversationThreadId ?: -1L }
|
||||
.combine(isFullScreenPane) { id, expanded -> if (expanded) -1L else id }
|
||||
.asObservable()
|
||||
|
||||
private val internalActiveCallId = MutableStateFlow<CallLogRow.Id?>(null)
|
||||
val observableActiveCallId: Observable<Optional<out CallLogRow.Id>> = internalActiveCallId.map { Optional.ofNullable(it) }.combine(isFullScreenPane) { id, expanded ->
|
||||
@@ -153,6 +165,24 @@ class MainNavigationViewModel(
|
||||
internalIsFullScreenPane.update { isFullScreenPane }
|
||||
}
|
||||
|
||||
fun setChatListSnapshotCaptureProvider(capture: suspend () -> Unit) {
|
||||
captureChatListSnapshot = capture
|
||||
}
|
||||
|
||||
fun onSplitPaneChanged(isSplitPane: Boolean) {
|
||||
this@MainNavigationViewModel.isSplitPane = isSplitPane
|
||||
chatsBackStack.updateEmptyDetailForPaneMode(isSplitPane)
|
||||
|
||||
// if no conversation is selected, clear the empty detail pane when switching from split pane to single pane mode.
|
||||
if (!isSplitPane &&
|
||||
internalMainNavigationState.value.currentListLocation.isChatsTab &&
|
||||
!chatsBackStack.hasConversation &&
|
||||
navigator?.scaffoldValue?.primary == PaneAdaptedValue.Expanded
|
||||
) {
|
||||
navigatorScope?.launch { navigator?.navigateBack() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the navigator on the view-model. This wraps the given navigator in our own delegating implementation
|
||||
* such that we can react to navigateTo/Back signals and maintain proper state for internalDetailLocation.
|
||||
@@ -161,18 +191,22 @@ class MainNavigationViewModel(
|
||||
this.navigatorScope = composeScope
|
||||
this.navigator = Nav(threePaneScaffoldNavigator)
|
||||
|
||||
val pendingFocus = earlyFocusedPaneRequested
|
||||
earlyFocusedPaneRequested = null
|
||||
|
||||
earlyNavigationListLocationRequested?.let {
|
||||
goTo(it)
|
||||
}
|
||||
|
||||
earlyNavigationListLocationRequested = null
|
||||
|
||||
earlyFocusedPaneRequested?.let {
|
||||
setFocusedPane(it)
|
||||
pendingFocus?.let { role ->
|
||||
if (role == ThreePaneScaffoldRole.Primary) {
|
||||
lockPaneToSecondary = false
|
||||
}
|
||||
setFocusedPane(role)
|
||||
}
|
||||
|
||||
earlyFocusedPaneRequested = null
|
||||
|
||||
earlyNavigationDetailLocationRequested?.let { detail ->
|
||||
lockPaneToSecondary = false
|
||||
updateActiveStateForLocation(detail)
|
||||
@@ -213,24 +247,10 @@ class MainNavigationViewModel(
|
||||
* This does not update what panel is currently focused, so that we can perform actions (such as first
|
||||
* render) *before* swapping panes. This helps to prevent flashing / duplicate loads.
|
||||
*/
|
||||
override fun goTo(location: MainNavigationDetailLocation) {
|
||||
when (location) {
|
||||
is MainNavigationDetailLocation.Empty,
|
||||
is MainNavigationDetailLocation.Chats.ConversationSettings,
|
||||
is MainNavigationDetailLocation.Chats.MessageDetails,
|
||||
is MainNavigationDetailLocation.CallLinkDetails,
|
||||
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> setDetailLocation(location)
|
||||
|
||||
is MainNavigationDetailLocation.Conversation -> goToConversation(location)
|
||||
}
|
||||
}
|
||||
override fun goTo(location: MainNavigationDetailLocation) = setDetailLocation(location)
|
||||
|
||||
private fun updateActiveStateForLocation(location: MainNavigationDetailLocation) {
|
||||
when (location) {
|
||||
is MainNavigationDetailLocation.Conversation -> {
|
||||
internalActiveChatThreadId.update { location.controllerKey }
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.CallLinkDetails -> {
|
||||
internalActiveCallId.update { location.controllerKey }
|
||||
}
|
||||
@@ -243,14 +263,14 @@ class MainNavigationViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun goToConversation(location: MainNavigationDetailLocation.Conversation) = viewModelScope.launch {
|
||||
val args = location.conversationArgs
|
||||
private suspend fun MainNavigationDetailLocation.Conversation.withPreloadedWallpaper(): MainNavigationDetailLocation.Conversation {
|
||||
val args = conversationArgs
|
||||
val liveRecipient = Recipient.live(args.recipientId)
|
||||
val recipientSnapshot = liveRecipient.get()
|
||||
val wallpaper = recipientSnapshot.wallpaper
|
||||
|
||||
val updatedArgs = if (recipientSnapshot.isResolving || (wallpaper?.isPhoto == true && !wallpaper.isPrefetched)) {
|
||||
withTimeoutOrNull(NAV_PREFETCH_TIMEOUT_MS) {
|
||||
withTimeoutOrNull(NAV_PREFETCH_TIMEOUT_MS.milliseconds) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val freshWallpaper = liveRecipient.resolve().wallpaper
|
||||
if (freshWallpaper?.prefetch(AppDependencies.application, NAV_PREFETCH_TIMEOUT_MS) == false) {
|
||||
@@ -266,19 +286,68 @@ class MainNavigationViewModel(
|
||||
args.copy(hasWallpaper = wallpaper != null)
|
||||
}
|
||||
|
||||
setDetailLocation(MainNavigationDetailLocation.Conversation(updatedArgs))
|
||||
return copy(conversationArgs = updatedArgs)
|
||||
}
|
||||
|
||||
private fun setDetailLocation(location: MainNavigationDetailLocation) {
|
||||
lockPaneToSecondary = false
|
||||
val currentListLocation = internalMainNavigationState.value.currentListLocation
|
||||
|
||||
if (navigator == null) {
|
||||
earlyNavigationDetailLocationRequested = location
|
||||
return
|
||||
when (location) {
|
||||
is MainNavigationDetailLocation.Empty if currentListLocation.isChatsTab -> chatsBackStack.reset(isSplitPane)
|
||||
is MainNavigationDetailLocation.Chats -> pushChatsDetailLocation(location)
|
||||
is MainNavigationDetailLocation.Conversation -> goToConversation(location)
|
||||
|
||||
is MainNavigationDetailLocation.Empty,
|
||||
is MainNavigationDetailLocation.CallLinkDetails,
|
||||
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
|
||||
if (navigator == null) {
|
||||
earlyNavigationDetailLocationRequested = location
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
internalDetailLocation.emit(location)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
internalDetailLocation.emit(location)
|
||||
private fun goToConversation(location: MainNavigationDetailLocation.Conversation) {
|
||||
val captureSnapshot = captureChatListSnapshot
|
||||
|
||||
if (captureSnapshot == null) {
|
||||
// share intent or process restore - push synchronously, since there's no chat-list snapshot to capture and no need to preload a wallpaper
|
||||
pushChatsDetailLocation(location)
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
captureSnapshot()
|
||||
pushChatsDetailLocation(location.withPreloadedWallpaper())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pushChatsDetailLocation(location: MainNavigationDetailLocation) {
|
||||
chatsBackStack.push(location)
|
||||
updateActiveStateForLocation(location)
|
||||
setFocusedPane(ThreePaneScaffoldRole.Primary)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of [pushChatsDetailLocation]. Pops the top chats detail entry and, if no conversation
|
||||
* remains, records the user's intent to stay on the list pane (so a subsequent config change does
|
||||
* not errantly restore them to the Primary/detail pane).
|
||||
*/
|
||||
fun popChatsDetailLocation() {
|
||||
chatsBackStack.pop()
|
||||
if (!chatsBackStack.hasConversation) {
|
||||
lockPaneToSecondary = true
|
||||
}
|
||||
}
|
||||
|
||||
fun onChatsDetailPaneCollapsed() {
|
||||
if (!chatsBackStack.hasConversation) {
|
||||
chatsBackStack.reset(isSplitPane)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ package org.signal.core.ui.navigation
|
||||
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -17,6 +19,7 @@ import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.scene.Scene
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.navigationevent.NavigationEvent
|
||||
|
||||
/**
|
||||
@@ -106,4 +109,14 @@ object TransitionSpecs {
|
||||
) + fadeOut(animationSpec = tween(DURATION))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No enter/exit animation.
|
||||
*/
|
||||
object None {
|
||||
val metadata: Map<String, Any> =
|
||||
NavDisplay.transitionSpec { EnterTransition.None togetherWith ExitTransition.None } +
|
||||
NavDisplay.popTransitionSpec { EnterTransition.None togetherWith ExitTransition.None } +
|
||||
NavDisplay.predictivePopTransitionSpec { EnterTransition.None togetherWith ExitTransition.None }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user