Prepare conversation fragment navigation for two-pane conversation settings.

This commit is contained in:
jeffrey-signal
2026-03-19 11:56:18 -04:00
committed by Cody Henthorne
parent 78d3db319c
commit 72cbe61f6c
9 changed files with 124 additions and 53 deletions

View File

@@ -118,6 +118,7 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.NewConversationActivity
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay
import org.thoughtcrime.securesms.conversation.v2.ShareDataTimestampViewModel
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
@@ -192,7 +193,15 @@ import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDeleg
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import org.signal.core.ui.R as CoreUiR
class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider, Material3OnScrollHelperBinder, ConversationListFragment.Callback, CallLogFragment.Callback, GooglePayComponent {
class MainActivity :
PassphraseRequiredActivity(),
VoiceNoteMediaControllerOwner,
MainNavigator.NavigatorProvider,
Material3OnScrollHelperBinder,
ConversationListFragment.Callback,
ConversationFragment.NavigationHost,
CallLogFragment.Callback,
GooglePayComponent {
companion object {
private val TAG = Log.tag(MainActivity::class)
@@ -496,6 +505,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
}
}
@@ -1276,4 +1286,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
}
}
override fun navigateTo(location: MainNavigationDetailLocation) {
mainNavigationViewModel.goTo(location)
}
}

View File

@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.components.settings.conversation
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
class CallInfoActivity : ConversationSettingsActivity(), ConversationSettingsFragment.Callback {
class CallInfoActivity : ConversationSettingsActivity(), ConversationSettingsFragment.TransitionCallback {
override val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
}

View File

@@ -7,7 +7,6 @@ import android.os.Bundle
import android.view.View
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat
import androidx.core.util.Pair
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
@@ -17,7 +16,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.DynamicConversationSettingsTheme
import org.thoughtcrime.securesms.util.DynamicTheme
open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettingsFragment.Callback {
open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettingsFragment.TransitionCallback {
override val dynamicTheme: DynamicTheme = DynamicConversationSettingsTheme()
@@ -27,7 +26,7 @@ open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSet
super.onCreate(savedInstanceState, ready)
}
override fun onContentWillRender() {
override fun onReadyForEnterTransition() {
ActivityCompat.startPostponedEnterTransition(this)
}

View File

@@ -120,6 +120,11 @@ private const val REQUEST_CODE_ADD_CONTACT = 2
private const val REQUEST_CODE_ADD_MEMBERS_TO_GROUP = 3
private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4
/**
* Settings screen for a conversation.
*
* Hosts that want shared element enter transitions should implement [TransitionCallback].
*/
class ConversationSettingsFragment :
DSLSettingsFragment(
layoutId = R.layout.conversation_settings_fragment,
@@ -156,7 +161,7 @@ class ConversationSettingsFragment :
}
)
private lateinit var callback: Callback
private var transitionCallback: TransitionCallback? = null
private lateinit var toolbar: Toolbar
private lateinit var toolbarAvatarContainer: FrameLayout
@@ -172,8 +177,7 @@ class ConversationSettingsFragment :
override fun onAttach(context: Context) {
super.onAttach(context)
callback = context as Callback
transitionCallback = context as? TransitionCallback
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -300,7 +304,7 @@ class ConversationSettingsFragment :
adapter.submitList(getConfiguration(state).toMappingModelList()) {
if (state.isLoaded) {
(view?.parent as? ViewGroup)?.doOnPreDraw {
callback.onContentWillRender()
transitionCallback?.onReadyForEnterTransition()
}
}
}
@@ -1136,7 +1140,13 @@ class ConversationSettingsFragment :
}
}
interface Callback {
fun onContentWillRender()
/**
* Implemented by hosts that postpone enter transitions (for example, shared element flows).
*
* Called when this fragment has loaded enough UI state to safely run the postponed enter
* transition.
*/
interface TransitionCallback {
fun onReadyForEnterTransition()
}
}

View File

@@ -564,6 +564,8 @@ class ConversationFragment :
private lateinit var conversationItemDecorations: ConversationItemDecorations
private lateinit var optionsMenuCallback: ConversationOptionsMenuCallback
private var navigationHost: NavigationHost? = null
private var animationsAllowed = false
private var pinnedShortcutReceiver: BroadcastReceiver? = null
private var searchMenuItem: MenuItem? = null
@@ -636,6 +638,11 @@ class ConversationFragment :
//region Android Lifecycle
override fun onAttach(context: Context) {
super.onAttach(context)
navigationHost = context as? NavigationHost
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
SignalLocalMetrics.ConversationOpen.start()
@@ -2904,11 +2911,7 @@ class ConversationFragment :
private fun handleDisplayDetails(conversationMessage: ConversationMessage) {
val recipientSnapshot = viewModel.recipientSnapshot ?: return
if (requireActivity() is MainActivity) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientSnapshot.id, conversationMessage.messageRecord.id))
} else {
MessageDetailsFragment.create(conversationMessage.messageRecord, recipientSnapshot.id).show(requireActivity().supportFragmentManager, MESSAGE_DETAILS_TAG)
}
navigateTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientSnapshot.id, MessageId(conversationMessage.messageRecord.id)))
}
private fun handleDeleteMessages(messageParts: Set<MultiselectPart>) {
@@ -3436,10 +3439,8 @@ class ConversationFragment :
.show(childFragmentManager)
} else if (messageRecord.hasFailedWithNetworkFailures()) {
ConversationDialogs.displayMessageCouldNotBeSentDialog(requireContext(), messageRecord)
} else if (requireActivity() is MainActivity) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientId, messageRecord.id))
} else {
MessageDetailsFragment.create(messageRecord, recipientId).show(requireActivity().supportFragmentManager, MESSAGE_DETAILS_TAG)
navigateTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientId, MessageId(messageRecord.id)))
}
}
@@ -4059,15 +4060,9 @@ class ConversationFragment :
}
override fun handleManageGroup() {
val recipient = viewModel.recipientSnapshot ?: return
val intent = ConversationSettingsActivity.forGroup(requireContext(), recipient.requireGroupId())
val bundle = ConversationSettingsActivity.createTransitionBundle(
requireContext(),
binding.conversationTitleView.root.findViewById(R.id.contact_photo_image),
binding.toolbar
)
requireActivity().startActivity(intent, bundle)
viewModel.recipientSnapshot?.let { recipient ->
navigateToConversationSettingsStandalone(recipient)
}
}
override fun handleLeavePushGroup() {
@@ -4101,24 +4096,11 @@ class ConversationFragment :
}
override fun handleConversationSettings() {
val recipient = viewModel.recipientSnapshot ?: return
if (recipient.isGroup) {
handleManageGroup()
return
viewModel.recipientSnapshot?.let { recipient ->
if (!viewModel.hasMessageRequestState || recipient.isBlocked) {
navigateToConversationSettingsStandalone(recipient)
}
}
if (viewModel.hasMessageRequestState && !recipient.isBlocked) {
return
}
val intent = ConversationSettingsActivity.forRecipient(requireContext(), recipient.id)
val bundle = ConversationSettingsActivity.createTransitionBundle(
requireActivity(),
binding.conversationTitleView.root.findViewById(R.id.contact_photo_image),
binding.toolbar
)
requireActivity().startActivity(intent, bundle)
}
override fun handleSelectMessageExpiration() {
@@ -4173,6 +4155,49 @@ class ConversationFragment :
}
}
/**
* Routes to the appropriate destination based on the current window configuration.
*
* In split-pane mode, delegates to the [NavigationHost] to display content in the detail pane. Otherwise, opens the destination as a standalone screen.
*/
private fun navigateTo(location: MainNavigationDetailLocation.Chats) {
val host = navigationHost
if (host != null && resources.getWindowSizeClass().isSplitPane()) {
host.navigateTo(location)
} else {
when (location) {
is MainNavigationDetailLocation.Chats.MessageDetails -> navigateToMessageDetailsStandalone(location)
is MainNavigationDetailLocation.Chats.Conversation -> error("ConversationFragment shouldn't navigate to another conversation - use the main navigation infrastructure instead.")
}
}
}
/**
* Opens message details as a standalone (single-pane) screen. Use [navigateTo] as the entry point.
*/
private fun navigateToMessageDetailsStandalone(location: MainNavigationDetailLocation.Chats.MessageDetails) {
MessageDetailsFragment.create(location.messageId, location.recipientId)
.show(requireActivity().supportFragmentManager, MESSAGE_DETAILS_TAG)
}
/**
* Opens conversation settings as a standalone (single-pane) screen.
*/
private fun navigateToConversationSettingsStandalone(recipient: Recipient) {
val intent = if (recipient.isPushGroup) {
ConversationSettingsActivity.forGroup(requireContext(), recipient.requireGroupId())
} else {
ConversationSettingsActivity.forRecipient(requireContext(), recipient.id)
}
val bundle = ConversationSettingsActivity.createTransitionBundle(
requireActivity(),
binding.conversationTitleView.root.findViewById(R.id.contact_photo_image),
binding.toolbar
)
requireActivity().startActivity(intent, bundle)
}
private inner class OnReactionsSelectedListener : ConversationReactionOverlay.OnReactionSelectedListener {
override fun onReactionSelected(messageRecord: MessageRecord, emoji: String?) {
reactionDelegate.hide()
@@ -5121,4 +5146,11 @@ class ConversationFragment :
override fun onDoubleTapEditEducationSheetNext(conversationMessage: ConversationMessage) {
handleEditMessage(conversationMessage)
}
/**
* Optional hook for host activity to intercept and handle detail navigation, used by split-pane layouts.
*/
interface NavigationHost {
fun navigateTo(location: MainNavigationDetailLocation)
}
}

View File

@@ -1,11 +1,14 @@
package org.thoughtcrime.securesms.database.model
import android.os.Bundle
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
/**
* Represents the primary key in a [MessageId].
*/
@Serializable
@Parcelize
data class MessageId(
val id: Long
@@ -14,6 +17,13 @@ data class MessageId(
return "$id|true"
}
class NavType : androidx.navigation.NavType<MessageId>(false) {
override fun get(bundle: Bundle, key: String): MessageId? = bundle.getLong(key, -1).takeIf { it >= 0 }?.let { MessageId(it) }
override fun parseValue(value: String): MessageId = MessageId(value.toLong())
override fun put(bundle: Bundle, key: String, value: MessageId) = bundle.putLong(key, value.id)
override fun serializeAsValue(value: MessageId): String = value.id.toString()
}
companion object {
/**
* Returns null for invalid IDs. Useful when pulling a possibly-unset ID from a database, or something like that.

View File

@@ -52,6 +52,7 @@ 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
@@ -145,7 +146,8 @@ fun NavGraphBuilder.chatNavGraphBuilder(
composable<MainNavigationDetailLocation.Chats.MessageDetails>(
typeMap = mapOf(
typeOf<RecipientId>() to JsonSerializableNavType(RecipientId.serializer())
typeOf<RecipientId>() to JsonSerializableNavType(RecipientId.serializer()),
typeOf<MessageId>() to MessageId.NavType()
)
) { navBackStackEntry ->
val context = LocalContext.current

View File

@@ -14,6 +14,7 @@ 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
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
@@ -67,7 +68,7 @@ sealed class MainNavigationDetailLocation : Parcelable {
}
@Serializable
data class MessageDetails(val recipientId: RecipientId, val messageId: Long) : Chats() {
data class MessageDetails(val recipientId: RecipientId, val messageId: MessageId) : Chats() {
@Transient
@IgnoredOnParcel
override val controllerKey: RecipientId = recipientId

View File

@@ -15,7 +15,9 @@ import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.signal.core.util.requireParcelableCompat
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.WrapperDialogFragment
@@ -30,6 +32,7 @@ import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.ui.edit.EditMessageHistoryDialog.Companion.show
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
@@ -92,9 +95,9 @@ class MessageDetailsFragment : Fragment(), MessageDetailsAdapter.Callbacks {
}
private fun initializeViewModel() {
val recipientId = requireArguments().getParcelable<RecipientId>(RECIPIENT_EXTRA)
val messageId = requireArguments().getLong(MESSAGE_ID_EXTRA, -1)
val factory = MessageDetailsViewModel.Factory(recipientId, messageId)
val recipientId = requireArguments().getParcelableCompat(RECIPIENT_EXTRA, RecipientId::class.java)
val messageId = requireArguments().requireParcelableCompat(MESSAGE_ID_EXTRA, MessageId::class.java)
val factory = MessageDetailsViewModel.Factory(recipientId, messageId.id)
viewModel = ViewModelProvider(this, factory)[MessageDetailsViewModel::class.java]
viewModel.messageDetails.observe(viewLifecycleOwner) { details: MessageDetails? ->
@@ -427,16 +430,16 @@ class MessageDetailsFragment : Fragment(), MessageDetailsAdapter.Callbacks {
private const val MESSAGE_ID_EXTRA = "message_id"
private const val RECIPIENT_EXTRA = "recipient_id"
fun args(recipientId: RecipientId, messageId: Long): Bundle {
fun args(recipientId: RecipientId, messageId: MessageId): Bundle {
return bundleOf(
MESSAGE_ID_EXTRA to messageId,
RECIPIENT_EXTRA to recipientId
)
}
fun create(message: MessageRecord, recipientId: RecipientId): Dialog {
fun create(messageId: MessageId, recipientId: RecipientId): Dialog {
return Dialog().apply {
arguments = args(recipientId, message.id)
arguments = args(recipientId, messageId)
}
}
}