mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 04:58:45 +00:00
Add new navigation and pane support.
This commit is contained in:
committed by
Jeffrey Starke
parent
146a5f5701
commit
fd999be41a
@@ -18,12 +18,12 @@ import android.view.ViewTreeObserver
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -51,7 +51,6 @@ import androidx.compose.runtime.setValue
|
||||
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.compose.ui.unit.dp
|
||||
import androidx.fragment.app.DialogFragment
|
||||
@@ -62,6 +61,7 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -78,6 +78,7 @@ import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
@@ -91,7 +92,6 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.manual.N
|
||||
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.v2.ConversationFragment
|
||||
import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay
|
||||
import org.thoughtcrime.securesms.conversation.v2.ShareDataTimestampViewModel
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
|
||||
@@ -104,6 +104,9 @@ 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.CallsNavHost
|
||||
import org.thoughtcrime.securesms.main.ChatsNavHost
|
||||
import org.thoughtcrime.securesms.main.EmptyDetailScreen
|
||||
import org.thoughtcrime.securesms.main.MainBottomChrome
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeState
|
||||
@@ -122,6 +125,7 @@ import org.thoughtcrime.securesms.main.MainToolbarViewModel
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.main.NavigationBarSpacerCompat
|
||||
import org.thoughtcrime.securesms.main.SnackbarState
|
||||
import org.thoughtcrime.securesms.main.StoriesNavHost
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone
|
||||
@@ -312,8 +316,11 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
}
|
||||
|
||||
val isNavigationVisible = remember(mainToolbarState.mode) {
|
||||
mainToolbarState.mode == MainToolbarMode.FULL
|
||||
val isNavigationVisible = mainToolbarState.mode == MainToolbarMode.FULL
|
||||
val isBackHandlerEnabled = mainToolbarState.destination != MainNavigationListLocation.CHATS
|
||||
|
||||
BackHandler(enabled = isBackHandlerEnabled) {
|
||||
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
|
||||
}
|
||||
|
||||
val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) {
|
||||
@@ -347,17 +354,13 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
val mutableInteractionSource = remember { MutableInteractionSource() }
|
||||
|
||||
LaunchedEffect(mainNavigationDetailLocation) {
|
||||
println("Detail Location Changed pane:${wrappedNavigator.currentDestination?.pane}")
|
||||
if (paneExpansionState.currentAnchor == listOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Primary) {
|
||||
println("Animate detail")
|
||||
paneExpansionState.animateTo(detailOnlyAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationState.currentListLocation) {
|
||||
println("List Location Changed pane:${wrappedNavigator.currentDestination?.pane}")
|
||||
if (paneExpansionState.currentAnchor == detailOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Secondary) {
|
||||
println("Animate list")
|
||||
paneExpansionState.animateTo(listOnlyAnchor)
|
||||
}
|
||||
}
|
||||
@@ -462,35 +465,33 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
},
|
||||
detailContent = {
|
||||
when (val destination = mainNavigationDetailLocation) {
|
||||
is MainNavigationDetailLocation.Conversation -> {
|
||||
val fragmentState = key(destination) { rememberFragmentState() }
|
||||
AndroidFragment(
|
||||
clazz = ConversationFragment::class.java,
|
||||
fragmentState = fragmentState,
|
||||
arguments = requireNotNull(destination.intent.extras) { "Handed null Conversation intent arguments." },
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
when (val location = mainNavigationDetailLocation) {
|
||||
MainNavigationDetailLocation.Empty -> {
|
||||
EmptyDetailScreen(contentLayoutData)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Chats -> {
|
||||
ChatsNavHost(
|
||||
currentDestination = location,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
}
|
||||
|
||||
MainNavigationDetailLocation.Empty -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_signal_logo_large),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
is MainNavigationDetailLocation.Calls -> {
|
||||
CallsNavHost(
|
||||
currentDestination = location,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Stories -> {
|
||||
val storiesNavigationController = rememberNavController()
|
||||
|
||||
StoriesNavHost(
|
||||
navHostController = storiesNavigationController,
|
||||
startDestination = location,
|
||||
contentLayoutData = contentLayoutData
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -551,8 +552,20 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
return remember(scaffoldNavigator, coroutine) {
|
||||
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator) { detailLocation ->
|
||||
when (detailLocation) {
|
||||
is MainNavigationDetailLocation.Conversation -> {
|
||||
startActivity(detailLocation.intent)
|
||||
is MainNavigationDetailLocation.Chats.Conversation -> {
|
||||
startActivity(
|
||||
ConversationIntents.createBuilderSync(this, detailLocation.conversationArgs.recipientId, detailLocation.conversationArgs.threadId)
|
||||
.withArgs(detailLocation.conversationArgs)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls.CallLinkDetails -> {
|
||||
startActivity(CallLinkDetailsActivity.createIntent(this, detailLocation.callLinkRoomId))
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls.EditCallLinkName -> {
|
||||
error("Unexpected subroute EditCallLinkName.")
|
||||
}
|
||||
|
||||
MainNavigationDetailLocation.Empty -> Unit
|
||||
@@ -775,7 +788,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
if (ConversationIntents.isConversationIntent(intent)) {
|
||||
Log.d(TAG, "Got conversation intent. Navigating to conversation.")
|
||||
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(intent))
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,8 +45,8 @@ public class MainNavigator {
|
||||
Disposable disposable = ConversationIntents.createBuilder(activity, recipientId, threadId)
|
||||
.map(builder -> builder.withDistributionType(distributionType)
|
||||
.withStartingPosition(startingPosition)
|
||||
.build())
|
||||
.subscribe(intent -> viewModel.goTo(new MainNavigationDetailLocation.Conversation(intent)));
|
||||
.toConversationArgs())
|
||||
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Chats.Conversation(args)));
|
||||
|
||||
lifecycleDisposable.add(disposable);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ import com.bumptech.glide.load.Key
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.load.BadgeSpriteTransformation
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.serialization.UriSerializer
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
@@ -28,12 +30,13 @@ typealias OnBadgeClicked = (Badge, Boolean, Boolean) -> Unit
|
||||
*/
|
||||
@Stable
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class Badge(
|
||||
val id: String,
|
||||
val category: Category,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val imageUrl: Uri,
|
||||
@Serializable(with = UriSerializer::class) val imageUrl: Uri,
|
||||
val imageDensity: String,
|
||||
val expirationTimestamp: Long,
|
||||
val visible: Boolean,
|
||||
|
||||
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.calls.links
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -35,11 +36,18 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.util.BreakIteratorCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsViewModel
|
||||
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
|
||||
class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
|
||||
|
||||
@@ -66,61 +74,109 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
|
||||
@Preview
|
||||
@Composable
|
||||
override fun DialogContent() {
|
||||
var callName by remember {
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
text = argName,
|
||||
selection = TextRange(argName.length)
|
||||
)
|
||||
EditCallLinkNameScreen(
|
||||
initialNameValue = argName,
|
||||
onSaveClick = {
|
||||
setFragmentResult(RESULT_KEY, bundleOf(RESULT_KEY to it))
|
||||
dismiss()
|
||||
},
|
||||
onNavigationClick = {
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditCallLinkNameScreen(
|
||||
roomId: CallLinkRoomId
|
||||
) {
|
||||
val viewModel: CallLinkDetailsViewModel = viewModel {
|
||||
CallLinkDetailsViewModel(roomId)
|
||||
}
|
||||
|
||||
val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current
|
||||
val lifecycleScope = LocalLifecycleOwner.current.lifecycleScope
|
||||
|
||||
EditCallLinkNameScreen(
|
||||
initialNameValue = viewModel.nameSnapshot,
|
||||
onSaveClick = {
|
||||
lifecycleScope.launch {
|
||||
viewModel.setName(it)
|
||||
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
|
||||
}
|
||||
},
|
||||
onNavigationClick = {
|
||||
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
|
||||
},
|
||||
showNavigationIcon = !WindowSizeClass.rememberWindowSizeClass().isSplitPane()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditCallLinkNameScreen(
|
||||
initialNameValue: String,
|
||||
onSaveClick: (String) -> Unit,
|
||||
onNavigationClick: () -> Unit,
|
||||
showNavigationIcon: Boolean = true
|
||||
) {
|
||||
var callName by remember {
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
text = initialNameValue,
|
||||
selection = TextRange(initialNameValue.length)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.EditCallLinkNameDialogFragment__edit_call_name),
|
||||
onNavigationClick = this::dismiss,
|
||||
navigationIcon = ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) { paddingValues ->
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val breakIterator = remember { BreakIteratorCompat.getInstance() }
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.EditCallLinkNameDialogFragment__edit_call_name),
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIcon = if (showNavigationIcon) {
|
||||
ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) { paddingValues ->
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val breakIterator = remember { BreakIteratorCompat.getInstance() }
|
||||
|
||||
Surface(modifier = Modifier.padding(paddingValues)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.gutter)
|
||||
)
|
||||
.padding(top = 20.dp, bottom = 16.dp)
|
||||
) {
|
||||
TextField(
|
||||
value = callName,
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__call_name))
|
||||
},
|
||||
onValueChange = {
|
||||
callName = it.copy(text = breakIterator.apply { setText(it.text) }.take(32).toString())
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
Surface(modifier = Modifier.padding(paddingValues)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.gutter)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Buttons.MediumTonal(
|
||||
onClick = {
|
||||
setFragmentResult(RESULT_KEY, bundleOf(RESULT_KEY to callName.text))
|
||||
dismiss()
|
||||
},
|
||||
modifier = Modifier.align(End)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__save))
|
||||
}
|
||||
.padding(top = 20.dp, bottom = 16.dp)
|
||||
) {
|
||||
TextField(
|
||||
value = callName,
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__call_name))
|
||||
},
|
||||
onValueChange = {
|
||||
callName = it.copy(text = breakIterator.apply { setText(it.text) }.take(32).toString())
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Buttons.MediumTonal(
|
||||
onClick = {
|
||||
onSaveClick(callName.text)
|
||||
},
|
||||
modifier = Modifier.align(End)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__save))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -7,22 +7,68 @@ package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRouter
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
class CallLinkDetailsActivity : FragmentWrapperActivity() {
|
||||
override fun getFragment(): Fragment = NavHostFragment.create(R.navigation.call_link_details, intent.extras!!.getBundle(BUNDLE))
|
||||
class CallLinkDetailsActivity : FragmentActivity() {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val BUNDLE = "bundle"
|
||||
private const val ARG_ROOM_ID = "room.id"
|
||||
|
||||
fun createIntent(context: Context, callLinkRoomId: CallLinkRoomId): Intent {
|
||||
return Intent(context, CallLinkDetailsActivity::class.java)
|
||||
.putExtra(BUNDLE, CallLinkDetailsFragmentArgs.Builder(callLinkRoomId).build().toBundle())
|
||||
.putExtra(ARG_ROOM_ID, callLinkRoomId)
|
||||
}
|
||||
}
|
||||
|
||||
private val roomId: CallLinkRoomId
|
||||
get() = intent.getParcelableExtraCompat(ARG_ROOM_ID, CallLinkRoomId::class.java)!!
|
||||
|
||||
private val viewModel: CallLinkDetailsViewModel by viewModel {
|
||||
CallLinkDetailsViewModel(roomId)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
SignalTheme {
|
||||
CallLinkDetailsScreen(
|
||||
roomId = roomId,
|
||||
viewModel = viewModel,
|
||||
router = remember { Router() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Router : MainNavigationRouter {
|
||||
override fun goTo(location: MainNavigationDetailLocation) {
|
||||
when (location) {
|
||||
is MainNavigationDetailLocation.Calls.EditCallLinkName -> {
|
||||
EditCallLinkNameDialogFragment().apply {
|
||||
arguments = bundleOf(EditCallLinkNameDialogFragment.ARG_NAME to viewModel.nameSnapshot)
|
||||
}.show(supportFragmentManager, null)
|
||||
}
|
||||
|
||||
else -> error("Unsupported route $location")
|
||||
}
|
||||
}
|
||||
|
||||
override fun goTo(location: MainNavigationListLocation) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallLinkState.Restrictions
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.YouAreAlreadyInACallSnackbar
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
|
||||
import org.thoughtcrime.securesms.calls.links.SignalCallRow
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
import org.thoughtcrime.securesms.sharing.v2.ShareActivity
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Provides detailed info about a call link and allows the owner of that link
|
||||
* to modify call properties.
|
||||
*/
|
||||
class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CallLinkDetailsFragment::class.java)
|
||||
}
|
||||
|
||||
private val args: CallLinkDetailsFragmentArgs by navArgs()
|
||||
private val viewModel: CallLinkDetailsViewModel by viewModels(factoryProducer = {
|
||||
CallLinkDetailsViewModel.Factory(args.roomId)
|
||||
})
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
|
||||
if (bundle.containsKey(resultKey)) {
|
||||
setName(bundle.getString(resultKey)!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val showAlreadyInACall by viewModel.showAlreadyInACall.collectAsStateWithLifecycle(false)
|
||||
|
||||
CallLinkDetails(
|
||||
state,
|
||||
showAlreadyInACall,
|
||||
this
|
||||
)
|
||||
}
|
||||
|
||||
override fun onNavigationClicked() {
|
||||
ActivityCompat.finishAfterTransition(requireActivity())
|
||||
}
|
||||
|
||||
override fun onJoinClicked() {
|
||||
val recipientSnapshot = viewModel.recipientSnapshot
|
||||
if (recipientSnapshot != null) {
|
||||
CommunicationActions.startVideoCall(this, recipientSnapshot) {
|
||||
viewModel.showAlreadyInACall(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEditNameClicked() {
|
||||
val name = viewModel.nameSnapshot
|
||||
findNavController().navigate(
|
||||
CallLinkDetailsFragmentDirections.actionCallLinkDetailsFragmentToEditCallLinkNameDialogFragment(name)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onShareClicked() {
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(requireContext())
|
||||
.setText(CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
|
||||
try {
|
||||
startActivity(shareIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCopyClicked() {
|
||||
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
override fun onShareLinkViaSignalClicked() {
|
||||
startActivity(
|
||||
ShareActivity.sendSimpleText(
|
||||
requireContext(),
|
||||
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDeleteClicked() {
|
||||
viewModel.setDisplayRevocationDialog(true)
|
||||
}
|
||||
|
||||
override fun onDeleteConfirmed() {
|
||||
viewModel.setDisplayRevocationDialog(false)
|
||||
lifecycleDisposable += viewModel.delete().observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is UpdateCallLinkResult.Delete -> ActivityCompat.finishAfterTransition(requireActivity())
|
||||
is UpdateCallLinkResult.CallLinkIsInUse -> {
|
||||
Log.w(TAG, "Failed to delete in-use call link.")
|
||||
toastCouldNotDeleteCallLink()
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Failed to delete call link. $it")
|
||||
toastFailure()
|
||||
}
|
||||
}
|
||||
}, onError = handleError("onDeleteClicked"))
|
||||
}
|
||||
|
||||
override fun onDeleteCanceled() {
|
||||
viewModel.setDisplayRevocationDialog(false)
|
||||
}
|
||||
|
||||
override fun onApproveAllMembersChanged(checked: Boolean) {
|
||||
lifecycleDisposable += viewModel.setApproveAllMembers(checked).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
|
||||
if (it is UpdateCallLinkResult.Failure) {
|
||||
Log.w(TAG, "Failed to change restrictions. $it")
|
||||
|
||||
if (it.status == 409.toShort()) {
|
||||
toastCallLinkInUse()
|
||||
} else {
|
||||
toastFailure()
|
||||
}
|
||||
}
|
||||
}, onError = handleError("onApproveAllMembersChanged"))
|
||||
}
|
||||
|
||||
private fun setName(name: String) {
|
||||
lifecycleDisposable += viewModel.setName(name).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
|
||||
if (it !is UpdateCallLinkResult.Update) {
|
||||
Log.w(TAG, "Failed to set name. $it")
|
||||
toastFailure()
|
||||
}
|
||||
}, onError = handleError("setName"))
|
||||
}
|
||||
|
||||
private fun handleError(method: String): (throwable: Throwable) -> Unit {
|
||||
return {
|
||||
Log.w(TAG, "Failure during $method", it)
|
||||
toastFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun toastCallLinkInUse() {
|
||||
Snackbar.make(requireView(), R.string.CallLinkDetailsFragment__couldnt_update_admin_approval, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun toastFailure() {
|
||||
Snackbar.make(requireView(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun toastCouldNotDeleteCallLink() {
|
||||
Snackbar.make(requireView(), R.string.CallLinkDetailsFragment__couldnt_delete_call_link, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private interface CallLinkDetailsCallback {
|
||||
fun onNavigationClicked()
|
||||
fun onJoinClicked()
|
||||
fun onEditNameClicked()
|
||||
fun onShareClicked()
|
||||
fun onCopyClicked()
|
||||
fun onShareLinkViaSignalClicked()
|
||||
fun onDeleteClicked()
|
||||
fun onDeleteConfirmed()
|
||||
fun onDeleteCanceled()
|
||||
fun onApproveAllMembersChanged(checked: Boolean)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun CallLinkDetailsPreview() {
|
||||
val callLink = remember {
|
||||
val credentials = CallLinkCredentials(
|
||||
byteArrayOf(1, 2, 3, 4),
|
||||
byteArrayOf(0, 1, 2, 3),
|
||||
byteArrayOf(3, 4, 5, 6)
|
||||
)
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 2, 3, 4)),
|
||||
credentials = credentials,
|
||||
state = SignalCallLinkState(
|
||||
name = "Call Name",
|
||||
revoked = false,
|
||||
restrictions = Restrictions.NONE,
|
||||
expiration = Instant.MAX
|
||||
),
|
||||
deletionTimestamp = 0L
|
||||
)
|
||||
}
|
||||
|
||||
SignalTheme(false) {
|
||||
CallLinkDetails(
|
||||
CallLinkDetailsState(
|
||||
false,
|
||||
false,
|
||||
callLink
|
||||
),
|
||||
true,
|
||||
object : CallLinkDetailsCallback {
|
||||
override fun onDeleteConfirmed() = Unit
|
||||
override fun onDeleteCanceled() = Unit
|
||||
override fun onNavigationClicked() = Unit
|
||||
override fun onJoinClicked() = Unit
|
||||
override fun onEditNameClicked() = Unit
|
||||
override fun onShareClicked() = Unit
|
||||
override fun onCopyClicked() = Unit
|
||||
override fun onShareLinkViaSignalClicked() = Unit
|
||||
override fun onDeleteClicked() = Unit
|
||||
override fun onApproveAllMembersChanged(checked: Boolean) = Unit
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallLinkDetails(
|
||||
state: CallLinkDetailsState,
|
||||
showAlreadyInACall: Boolean,
|
||||
callback: CallLinkDetailsCallback
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.CallLinkDetailsFragment__call_details),
|
||||
snackbarHost = {
|
||||
YouAreAlreadyInACallSnackbar(showAlreadyInACall)
|
||||
},
|
||||
onNavigationClick = callback::onNavigationClicked,
|
||||
navigationIcon = ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24)
|
||||
) { paddingValues ->
|
||||
if (state.callLink == null) {
|
||||
return@Settings
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
SignalCallRow(
|
||||
callLink = state.callLink,
|
||||
callLinkPeekInfo = state.peekInfo,
|
||||
onJoinClicked = callback::onJoinClicked,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
|
||||
)
|
||||
|
||||
if (state.callLink.credentials?.adminPassBytes != null) {
|
||||
Rows.TextRow(
|
||||
text = stringResource(
|
||||
id = if (state.callLink.state.name.isEmpty()) {
|
||||
R.string.CreateCallLinkBottomSheetDialogFragment__add_call_name
|
||||
} else {
|
||||
R.string.CreateCallLinkBottomSheetDialogFragment__edit_call_name
|
||||
}
|
||||
),
|
||||
onClick = callback::onEditNameClicked
|
||||
)
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__require_admin_approval),
|
||||
onCheckChanged = callback::onApproveAllMembersChanged,
|
||||
isLoading = state.isLoadingAdminApprovalChange
|
||||
)
|
||||
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
|
||||
onClick = callback::onShareLinkViaSignalClicked
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
|
||||
onClick = callback::onCopyClicked
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
|
||||
onClick = callback::onShareClicked
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
|
||||
foregroundTint = MaterialTheme.colorScheme.error,
|
||||
onClick = callback::onDeleteClicked
|
||||
)
|
||||
}
|
||||
|
||||
if (state.displayRevocationDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.CallLinkDetailsFragment__delete_link),
|
||||
body = stringResource(id = R.string.CallLinkDetailsFragment__this_link_will_no_longer_work),
|
||||
confirm = stringResource(id = R.string.delete),
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onConfirm = callback::onDeleteConfirmed,
|
||||
onDismiss = callback::onDeleteCanceled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.ringrtc.CallLinkState.Restrictions
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.YouAreAlreadyInACallSnackbar
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.SignalCallRow
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRouter
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import org.thoughtcrime.securesms.sharing.v2.ShareActivity
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
fun CallLinkDetailsScreen(
|
||||
roomId: CallLinkRoomId,
|
||||
viewModel: CallLinkDetailsViewModel = viewModel {
|
||||
CallLinkDetailsViewModel(roomId)
|
||||
},
|
||||
router: MainNavigationRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalContext.current as ComponentActivity) {
|
||||
error("Should already be created.")
|
||||
}
|
||||
) {
|
||||
val activity = LocalContext.current as FragmentActivity
|
||||
val callback = remember {
|
||||
DefaultCallLinkDetailsCallback(
|
||||
activity = activity,
|
||||
viewModel = viewModel,
|
||||
router = router
|
||||
)
|
||||
}
|
||||
|
||||
val state by viewModel.state.collectAsStateWithLifecycle(activity)
|
||||
val showAlreadyInACall by viewModel.showAlreadyInACall.collectAsStateWithLifecycle(initialValue = false, lifecycleOwner = activity)
|
||||
|
||||
CallLinkDetailsScreen(
|
||||
state = state,
|
||||
showAlreadyInACall = showAlreadyInACall,
|
||||
callback = callback,
|
||||
showNavigationIcon = !WindowSizeClass.rememberWindowSizeClass().isSplitPane()
|
||||
)
|
||||
}
|
||||
|
||||
class DefaultCallLinkDetailsCallback(
|
||||
private val activity: FragmentActivity,
|
||||
private val viewModel: CallLinkDetailsViewModel,
|
||||
private val router: MainNavigationRouter
|
||||
) : CallLinkDetailsCallback {
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
init {
|
||||
lifecycleDisposable.bindTo(activity)
|
||||
}
|
||||
|
||||
override fun onNavigationClicked() {
|
||||
activity.onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onJoinClicked() {
|
||||
val recipientSnapshot = viewModel.recipientSnapshot
|
||||
if (recipientSnapshot != null) {
|
||||
CommunicationActions.startVideoCall(activity, recipientSnapshot) {
|
||||
viewModel.showAlreadyInACall(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEditNameClicked() {
|
||||
router.goTo(MainNavigationDetailLocation.Calls.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
|
||||
}
|
||||
|
||||
override fun onShareClicked() {
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(activity)
|
||||
.setText(CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
|
||||
try {
|
||||
activity.startActivity(shareIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCopyClicked() {
|
||||
Util.copyToClipboard(activity, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
override fun onShareLinkViaSignalClicked() {
|
||||
activity.startActivity(
|
||||
ShareActivity.sendSimpleText(
|
||||
activity,
|
||||
activity.getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDeleteClicked() {
|
||||
viewModel.setDisplayRevocationDialog(true)
|
||||
}
|
||||
|
||||
override fun onDeleteConfirmed() {
|
||||
viewModel.setDisplayRevocationDialog(false)
|
||||
activity.lifecycleScope.launch {
|
||||
if (viewModel.delete()) {
|
||||
router.goTo(MainNavigationListLocation.CALLS)
|
||||
router.goTo(MainNavigationDetailLocation.Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDeleteCanceled() {
|
||||
viewModel.setDisplayRevocationDialog(false)
|
||||
}
|
||||
|
||||
override fun onApproveAllMembersChanged(checked: Boolean) {
|
||||
activity.lifecycleScope.launch {
|
||||
viewModel.setApproveAllMembers(checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CallLinkDetailsCallback {
|
||||
fun onNavigationClicked() = Unit
|
||||
fun onJoinClicked() = Unit
|
||||
fun onEditNameClicked() = Unit
|
||||
fun onShareClicked() = Unit
|
||||
fun onCopyClicked() = Unit
|
||||
fun onShareLinkViaSignalClicked() = Unit
|
||||
fun onDeleteClicked() = Unit
|
||||
fun onDeleteConfirmed() = Unit
|
||||
fun onDeleteCanceled() = Unit
|
||||
fun onApproveAllMembersChanged(checked: Boolean) = Unit
|
||||
|
||||
object Empty : CallLinkDetailsCallback
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallLinkDetailsScreen(
|
||||
state: CallLinkDetailsState,
|
||||
showAlreadyInACall: Boolean,
|
||||
callback: CallLinkDetailsCallback,
|
||||
showNavigationIcon: Boolean = true
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.CallLinkDetailsFragment__call_details),
|
||||
snackbarHost = {
|
||||
YouAreAlreadyInACallSnackbar(showAlreadyInACall)
|
||||
FailureSnackbar(failureSnackbar = state.failureSnackbar)
|
||||
},
|
||||
onNavigationClick = callback::onNavigationClicked,
|
||||
navigationIcon = if (showNavigationIcon) {
|
||||
ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (state.callLink == null) {
|
||||
return@Settings
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
item {
|
||||
SignalCallRow(
|
||||
callLink = state.callLink,
|
||||
callLinkPeekInfo = state.peekInfo,
|
||||
onJoinClicked = callback::onJoinClicked,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.callLink.credentials?.adminPassBytes != null) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(
|
||||
id = if (state.callLink.state.name.isEmpty()) {
|
||||
R.string.CreateCallLinkBottomSheetDialogFragment__add_call_name
|
||||
} else {
|
||||
R.string.CreateCallLinkBottomSheetDialogFragment__edit_call_name
|
||||
}
|
||||
),
|
||||
onClick = callback::onEditNameClicked
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__require_admin_approval),
|
||||
onCheckChanged = callback::onApproveAllMembersChanged,
|
||||
isLoading = state.isLoadingAdminApprovalChange
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
|
||||
onClick = callback::onShareLinkViaSignalClicked
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
|
||||
onClick = callback::onCopyClicked
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
|
||||
onClick = callback::onShareClicked
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
|
||||
foregroundTint = MaterialTheme.colorScheme.error,
|
||||
onClick = callback::onDeleteClicked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.displayRevocationDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.CallLinkDetailsFragment__delete_link),
|
||||
body = stringResource(id = R.string.CallLinkDetailsFragment__this_link_will_no_longer_work),
|
||||
confirm = stringResource(id = R.string.delete),
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onConfirm = callback::onDeleteConfirmed,
|
||||
onDismiss = callback::onDeleteCanceled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailureSnackbar(
|
||||
failureSnackbar: CallLinkDetailsState.FailureSnackbar?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val message: String? = when (failureSnackbar) {
|
||||
CallLinkDetailsState.FailureSnackbar.COULD_NOT_DELETE_CALL_LINK -> stringResource(R.string.CallLinkDetailsFragment__couldnt_delete_call_link)
|
||||
CallLinkDetailsState.FailureSnackbar.COULD_NOT_SAVE_CHANGES -> stringResource(R.string.CallLinkDetailsFragment__couldnt_save_changes)
|
||||
CallLinkDetailsState.FailureSnackbar.COULD_NOT_UPDATE_ADMIN_APPROVAL -> stringResource(R.string.CallLinkDetailsFragment__couldnt_update_admin_approval)
|
||||
null -> null
|
||||
}
|
||||
|
||||
val hostState = remember { SnackbarHostState() }
|
||||
Snackbars.Host(hostState, modifier = modifier)
|
||||
|
||||
LaunchedEffect(message) {
|
||||
if (message != null) {
|
||||
hostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun CallLinkDetailsScreenPreview() {
|
||||
val callLink = remember {
|
||||
val credentials = CallLinkCredentials(
|
||||
byteArrayOf(1, 2, 3, 4),
|
||||
byteArrayOf(0, 1, 2, 3),
|
||||
byteArrayOf(3, 4, 5, 6)
|
||||
)
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 2, 3, 4)),
|
||||
credentials = credentials,
|
||||
state = SignalCallLinkState(
|
||||
name = "Call Name",
|
||||
revoked = false,
|
||||
restrictions = Restrictions.NONE,
|
||||
expiration = Instant.MAX
|
||||
),
|
||||
deletionTimestamp = 0L
|
||||
)
|
||||
}
|
||||
|
||||
SignalTheme(false) {
|
||||
CallLinkDetailsScreen(
|
||||
CallLinkDetailsState(
|
||||
false,
|
||||
false,
|
||||
callLink
|
||||
),
|
||||
true,
|
||||
CallLinkDetailsCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,19 @@
|
||||
|
||||
package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.service.webrtc.CallLinkPeekInfo
|
||||
|
||||
@Immutable
|
||||
data class CallLinkDetailsState(
|
||||
val displayRevocationDialog: Boolean = false,
|
||||
val isLoadingAdminApprovalChange: Boolean = false,
|
||||
val callLink: CallLinkTable.CallLink? = null,
|
||||
val peekInfo: CallLinkPeekInfo? = null
|
||||
)
|
||||
val peekInfo: CallLinkPeekInfo? = null,
|
||||
val failureSnackbar: FailureSnackbar? = null
|
||||
) {
|
||||
enum class FailureSnackbar {
|
||||
COULD_NOT_DELETE_CALL_LINK,
|
||||
COULD_NOT_SAVE_CHANGES,
|
||||
COULD_NOT_UPDATE_ADMIN_APPROVAL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,16 @@ package org.thoughtcrime.securesms.calls.links.details
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallLinkState
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
|
||||
@@ -24,12 +24,20 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CallLinkDetailsViewModel(
|
||||
callLinkRoomId: CallLinkRoomId,
|
||||
repository: CallLinkDetailsRepository = CallLinkDetailsRepository(),
|
||||
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CallLinkDetailsViewModel::class)
|
||||
}
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val _state: MutableStateFlow<CallLinkDetailsState> = MutableStateFlow(CallLinkDetailsState())
|
||||
@@ -54,7 +62,6 @@ class CallLinkDetailsViewModel(
|
||||
disposables += repository.refreshCallLinkState(callLinkRoomId)
|
||||
disposables += CallLinks.watchCallLink(callLinkRoomId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy { callLink ->
|
||||
_state.update { it.copy(callLink = callLink) }
|
||||
}
|
||||
@@ -75,7 +82,6 @@ class CallLinkDetailsViewModel(
|
||||
.toObservable()
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy { callLinkPeekInfo ->
|
||||
_state.update { it.copy(peekInfo = callLinkPeekInfo) }
|
||||
}
|
||||
@@ -94,26 +100,102 @@ class CallLinkDetailsViewModel(
|
||||
_state.update { it.copy(displayRevocationDialog = displayRevocationDialog) }
|
||||
}
|
||||
|
||||
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
|
||||
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
|
||||
return mutationRepository
|
||||
.setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE)
|
||||
.doOnSubscribe {
|
||||
_state.update { it.copy(isLoadingAdminApprovalChange = true) }
|
||||
}
|
||||
.doFinally {
|
||||
_state.update { it.copy(isLoadingAdminApprovalChange = false) }
|
||||
suspend fun setApproveAllMembers(approveAllMembers: Boolean) {
|
||||
val result = suspendCoroutine { continuation ->
|
||||
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
|
||||
disposables += mutationRepository
|
||||
.setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE)
|
||||
.doOnSubscribe {
|
||||
_state.update { it.copy(isLoadingAdminApprovalChange = true) }
|
||||
}
|
||||
.doFinally {
|
||||
_state.update { it.copy(isLoadingAdminApprovalChange = false) }
|
||||
}
|
||||
.subscribeBy(
|
||||
onSuccess = { continuation.resume(Result.success(it)) },
|
||||
onError = { continuation.resume(Result.failure(it)) }
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
if (result == null) {
|
||||
handleError("setApproveAllMembers")
|
||||
return
|
||||
}
|
||||
|
||||
if (result is UpdateCallLinkResult.Failure) {
|
||||
Log.w(TAG, "Failed to change restrictions. $result")
|
||||
|
||||
if (result.status == 409.toShort()) {
|
||||
toastCallLinkInUse()
|
||||
} else {
|
||||
toastFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setName(name: String): Single<UpdateCallLinkResult> {
|
||||
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
|
||||
return mutationRepository.setCallName(credentials, name)
|
||||
suspend fun setName(name: String) {
|
||||
val result = suspendCoroutine { continuation ->
|
||||
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
|
||||
disposables += mutationRepository.setCallName(credentials, name)
|
||||
.subscribeBy(
|
||||
onSuccess = { continuation.resume(Result.success(it)) },
|
||||
onError = { continuation.resume(Result.failure(it)) }
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
if (result == null) {
|
||||
handleError("setName")
|
||||
} else {
|
||||
if (result !is UpdateCallLinkResult.Update) {
|
||||
Log.w(TAG, "Failed to set name. $name")
|
||||
toastFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(): Single<UpdateCallLinkResult> {
|
||||
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
|
||||
return mutationRepository.deleteCallLink(credentials)
|
||||
suspend fun delete(): Boolean {
|
||||
val result = suspendCoroutine { continuation ->
|
||||
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
|
||||
disposables += mutationRepository.deleteCallLink(credentials)
|
||||
.subscribeBy(
|
||||
onSuccess = { continuation.resume(Result.success(it)) },
|
||||
onError = { continuation.resume(Result.failure(it)) }
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
when (result) {
|
||||
null -> handleError("delete")
|
||||
is UpdateCallLinkResult.Delete -> return true
|
||||
is UpdateCallLinkResult.CallLinkIsInUse -> {
|
||||
Log.w(TAG, "Failed to delete in-use call link.")
|
||||
toastCouldNotDeleteCallLink()
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Failed to delete call link. $result")
|
||||
toastFailure()
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun handleError(method: String): (throwable: Throwable) -> Unit {
|
||||
return {
|
||||
Log.w(TAG, "Failure during $method", it)
|
||||
toastFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun toastCallLinkInUse() {
|
||||
_state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_UPDATE_ADMIN_APPROVAL) }
|
||||
}
|
||||
|
||||
private fun toastFailure() {
|
||||
_state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_SAVE_CHANGES) }
|
||||
}
|
||||
|
||||
private fun toastCouldNotDeleteCallLink() {
|
||||
_state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_DELETE_CALL_LINK) }
|
||||
}
|
||||
|
||||
class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory {
|
||||
|
||||
@@ -11,12 +11,16 @@ import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.Flowables
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.concurrent.addTo
|
||||
@@ -41,6 +45,7 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
|
||||
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
|
||||
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.MainToolbarMode
|
||||
@@ -78,6 +83,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
private lateinit var callLogActionMode: CallLogActionMode
|
||||
private val conversationUpdateTick: ConversationUpdateTick = ConversationUpdateTick(this::onTimestampTick)
|
||||
private var callLogAdapter: CallLogAdapter? = null
|
||||
private val backPressedCallback = OnBackPressed()
|
||||
|
||||
private lateinit var signalBottomActionBarController: SignalBottomActionBarController
|
||||
|
||||
@@ -165,16 +171,14 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
initializePullToFilter(scrollToPositionDelegate)
|
||||
initializeTapToScrollToTop(scrollToPositionDelegate)
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (!closeSearchIfOpen()) {
|
||||
mainNavigationViewModel.onChatsSelected()
|
||||
}
|
||||
requireActivity().onBackPressedDispatcher.addCallback(backPressedCallback)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
mainToolbarViewModel.state.collect {
|
||||
backPressedCallback.isEnabled = it.mode == MainToolbarMode.SEARCH
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (resources.getWindowSizeClass().isCompact()) {
|
||||
ViewUtil.setBottomMargin(binding.bottomActionBar, ViewUtil.getNavigationBarHeight(binding.bottomActionBar))
|
||||
@@ -316,7 +320,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
|
||||
viewModel.toggleSelected(callLogRow.id)
|
||||
} else {
|
||||
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.record.roomId))
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Calls.CallLinkDetails(callLogRow.record.roomId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,6 +486,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
}
|
||||
}
|
||||
|
||||
private inner class OnBackPressed : OnBackPressedCallback(enabled = false) {
|
||||
override fun handleOnBackPressed() {
|
||||
closeSearchIfOpen()
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onMultiSelectStarted()
|
||||
fun onMultiSelectFinished()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents.ConversationScreenType
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.mms.SlideFactory
|
||||
import org.thoughtcrime.securesms.recipients.Recipient.Companion.resolved
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.serialization.UriSerializer
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class ConversationArgs(
|
||||
val recipientId: RecipientId,
|
||||
@JvmField val threadId: Long,
|
||||
val draftText: String?,
|
||||
@Serializable(with = UriSerializer::class) val draftMedia: Uri?,
|
||||
val draftContentType: String?,
|
||||
val media: List<Media?>?,
|
||||
val stickerLocator: StickerLocator?,
|
||||
val isBorderless: Boolean,
|
||||
val distributionType: Int,
|
||||
val startingPosition: Int,
|
||||
val isFirstTimeInSelfCreatedGroup: Boolean,
|
||||
val isWithSearchOpen: Boolean,
|
||||
val giftBadge: Badge?,
|
||||
val shareDataTimestamp: Long,
|
||||
val conversationScreenType: ConversationScreenType
|
||||
) : Parcelable {
|
||||
@IgnoredOnParcel
|
||||
val draftMediaType: SlideFactory.MediaType? = SlideFactory.MediaType.from(draftContentType)
|
||||
|
||||
@IgnoredOnParcel
|
||||
val wallpaper: ChatWallpaper?
|
||||
get() = resolved(recipientId).wallpaper
|
||||
|
||||
@IgnoredOnParcel
|
||||
val chatColors: ChatColors
|
||||
get() = resolved(recipientId).chatColors
|
||||
|
||||
fun canInitializeFromDatabase(): Boolean {
|
||||
return draftText == null && (draftMedia == null || ConversationIntents.isBubbleIntentUri(draftMedia) || ConversationIntents.isNotificationIntentUri(draftMedia)) && draftMediaType == null
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,13 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadTable;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mms.SlideFactory;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
|
||||
import org.whispersystems.signalservice.api.util.Preconditions;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -99,6 +96,12 @@ public class ConversationIntents {
|
||||
return new Builder(context, ConversationActivity.class, recipientId, threadId, ConversationScreenType.NORMAL);
|
||||
}
|
||||
|
||||
public static @NonNull Builder createBuilderSync(@NonNull Context context, @NonNull ConversationArgs conversationArgs) {
|
||||
Preconditions.checkArgument(conversationArgs.threadId > 0, "threadId is invalid");
|
||||
return new Builder(context, ConversationActivity.class, conversationArgs.getRecipientId(), conversationArgs.threadId, ConversationScreenType.NORMAL)
|
||||
.withArgs(conversationArgs);
|
||||
}
|
||||
|
||||
static @Nullable Uri getIntentData(@NonNull Bundle bundle) {
|
||||
return bundle.getParcelable(INTENT_DATA);
|
||||
}
|
||||
@@ -132,170 +135,41 @@ public class ConversationIntents {
|
||||
return ACTION.equals(intent.getAction());
|
||||
}
|
||||
|
||||
public final static class Args {
|
||||
private final RecipientId recipientId;
|
||||
private final long threadId;
|
||||
private final String draftText;
|
||||
private final Uri draftMedia;
|
||||
private final String draftContentType;
|
||||
private final SlideFactory.MediaType draftMediaType;
|
||||
private final ArrayList<Media> media;
|
||||
private final StickerLocator stickerLocator;
|
||||
private final boolean isBorderless;
|
||||
private final int distributionType;
|
||||
private final int startingPosition;
|
||||
private final boolean firstTimeInSelfCreatedGroup;
|
||||
private final boolean withSearchOpen;
|
||||
private final Badge giftBadge;
|
||||
private final long shareDataTimestamp;
|
||||
private final ConversationScreenType conversationScreenType;
|
||||
|
||||
public static Args from(@NonNull Bundle arguments) {
|
||||
Uri intentDataUri = getIntentData(arguments);
|
||||
if (isBubbleIntentUri(intentDataUri)) {
|
||||
return new Args(RecipientId.from(intentDataUri.getQueryParameter(EXTRA_RECIPIENT)),
|
||||
Long.parseLong(intentDataUri.getQueryParameter(EXTRA_THREAD_ID)),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
ThreadTable.DistributionTypes.DEFAULT,
|
||||
-1,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
-1L,
|
||||
ConversationScreenType.BUBBLE);
|
||||
}
|
||||
|
||||
return new Args(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
|
||||
arguments.getLong(EXTRA_THREAD_ID, -1),
|
||||
arguments.getString(EXTRA_TEXT),
|
||||
ConversationIntents.getIntentData(arguments),
|
||||
ConversationIntents.getIntentType(arguments),
|
||||
arguments.getParcelableArrayList(EXTRA_MEDIA),
|
||||
arguments.getParcelable(EXTRA_STICKER),
|
||||
arguments.getBoolean(EXTRA_BORDERLESS, false),
|
||||
arguments.getInt(EXTRA_DISTRIBUTION_TYPE, ThreadTable.DistributionTypes.DEFAULT),
|
||||
arguments.getInt(EXTRA_STARTING_POSITION, -1),
|
||||
arguments.getBoolean(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false),
|
||||
arguments.getBoolean(EXTRA_WITH_SEARCH_OPEN, false),
|
||||
arguments.getParcelable(EXTRA_GIFT_BADGE),
|
||||
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
|
||||
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)));
|
||||
public static ConversationArgs readArgsFromBundle(@NonNull Bundle arguments) {
|
||||
Uri intentDataUri = getIntentData(arguments);
|
||||
if (isBubbleIntentUri(intentDataUri)) {
|
||||
return new ConversationArgs(RecipientId.from(intentDataUri.getQueryParameter(EXTRA_RECIPIENT)),
|
||||
Long.parseLong(intentDataUri.getQueryParameter(EXTRA_THREAD_ID)),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
ThreadTable.DistributionTypes.DEFAULT,
|
||||
-1,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
-1L,
|
||||
ConversationScreenType.BUBBLE);
|
||||
}
|
||||
|
||||
private Args(@NonNull RecipientId recipientId,
|
||||
long threadId,
|
||||
@Nullable String draftText,
|
||||
@Nullable Uri draftMedia,
|
||||
@Nullable String draftContentType,
|
||||
@Nullable ArrayList<Media> media,
|
||||
@Nullable StickerLocator stickerLocator,
|
||||
boolean isBorderless,
|
||||
int distributionType,
|
||||
int startingPosition,
|
||||
boolean firstTimeInSelfCreatedGroup,
|
||||
boolean withSearchOpen,
|
||||
@Nullable Badge giftBadge,
|
||||
long shareDataTimestamp,
|
||||
@NonNull ConversationScreenType conversationScreenType)
|
||||
{
|
||||
this.recipientId = recipientId;
|
||||
this.threadId = threadId;
|
||||
this.draftText = draftText;
|
||||
this.draftMedia = draftMedia;
|
||||
this.draftContentType = draftContentType;
|
||||
this.media = media;
|
||||
this.stickerLocator = stickerLocator;
|
||||
this.isBorderless = isBorderless;
|
||||
this.distributionType = distributionType;
|
||||
this.startingPosition = startingPosition;
|
||||
this.firstTimeInSelfCreatedGroup = firstTimeInSelfCreatedGroup;
|
||||
this.withSearchOpen = withSearchOpen;
|
||||
this.giftBadge = giftBadge;
|
||||
this.shareDataTimestamp = shareDataTimestamp;
|
||||
this.conversationScreenType = conversationScreenType;
|
||||
this.draftMediaType = SlideFactory.MediaType.from(draftContentType);
|
||||
}
|
||||
|
||||
public @NonNull RecipientId getRecipientId() {
|
||||
return recipientId;
|
||||
}
|
||||
|
||||
public long getThreadId() {
|
||||
return threadId;
|
||||
}
|
||||
|
||||
public @Nullable String getDraftText() {
|
||||
return draftText;
|
||||
}
|
||||
|
||||
public @Nullable Uri getDraftMedia() {
|
||||
return draftMedia;
|
||||
}
|
||||
|
||||
public @Nullable String getDraftContentType() {
|
||||
return draftContentType;
|
||||
}
|
||||
|
||||
public @Nullable SlideFactory.MediaType getDraftMediaType() {
|
||||
return draftMediaType;
|
||||
}
|
||||
|
||||
public @Nullable ArrayList<Media> getMedia() {
|
||||
return media;
|
||||
}
|
||||
|
||||
public @Nullable StickerLocator getStickerLocator() {
|
||||
return stickerLocator;
|
||||
}
|
||||
|
||||
public int getDistributionType() {
|
||||
return distributionType;
|
||||
}
|
||||
|
||||
public int getStartingPosition() {
|
||||
return startingPosition;
|
||||
}
|
||||
|
||||
public boolean isBorderless() {
|
||||
return isBorderless;
|
||||
}
|
||||
|
||||
public boolean isFirstTimeInSelfCreatedGroup() {
|
||||
return firstTimeInSelfCreatedGroup;
|
||||
}
|
||||
|
||||
public @Nullable ChatWallpaper getWallpaper() {
|
||||
return Recipient.resolved(recipientId).getWallpaper();
|
||||
}
|
||||
|
||||
public @NonNull ChatColors getChatColors() {
|
||||
return Recipient.resolved(recipientId).getChatColors();
|
||||
}
|
||||
|
||||
public boolean isWithSearchOpen() {
|
||||
return withSearchOpen;
|
||||
}
|
||||
|
||||
public @Nullable Badge getGiftBadge() {
|
||||
return giftBadge;
|
||||
}
|
||||
|
||||
public long getShareDataTimestamp() {
|
||||
return shareDataTimestamp;
|
||||
}
|
||||
|
||||
public @NonNull ConversationScreenType getConversationScreenType() {
|
||||
return conversationScreenType;
|
||||
}
|
||||
|
||||
public boolean canInitializeFromDatabase() {
|
||||
return draftText == null && (draftMedia == null || ConversationIntents.isBubbleIntentUri(draftMedia) || ConversationIntents.isNotificationIntentUri(draftMedia)) && draftMediaType == null;
|
||||
}
|
||||
return new ConversationArgs(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
|
||||
arguments.getLong(EXTRA_THREAD_ID, -1),
|
||||
arguments.getString(EXTRA_TEXT),
|
||||
ConversationIntents.getIntentData(arguments),
|
||||
ConversationIntents.getIntentType(arguments),
|
||||
arguments.getParcelableArrayList(EXTRA_MEDIA),
|
||||
arguments.getParcelable(EXTRA_STICKER),
|
||||
arguments.getBoolean(EXTRA_BORDERLESS, false),
|
||||
arguments.getInt(EXTRA_DISTRIBUTION_TYPE, ThreadTable.DistributionTypes.DEFAULT),
|
||||
arguments.getInt(EXTRA_STARTING_POSITION, -1),
|
||||
arguments.getBoolean(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false),
|
||||
arguments.getBoolean(EXTRA_WITH_SEARCH_OPEN, false),
|
||||
arguments.getParcelable(EXTRA_GIFT_BADGE),
|
||||
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
|
||||
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)));
|
||||
}
|
||||
|
||||
public final static class Builder {
|
||||
@@ -331,6 +205,23 @@ public class ConversationIntents {
|
||||
this.conversationScreenType = conversationScreenType;
|
||||
}
|
||||
|
||||
public @NonNull Builder withArgs(@NonNull ConversationArgs args) {
|
||||
draftText = args.getDraftText();
|
||||
media = args.getMedia();
|
||||
stickerLocator = args.getStickerLocator();
|
||||
isBorderless = args.isBorderless();
|
||||
distributionType = args.getDistributionType();
|
||||
startingPosition = args.getStartingPosition();
|
||||
dataType = args.getDraftContentType();
|
||||
dataUri = args.getDraftMedia();
|
||||
firstTimeInSelfCreatedGroup = args.isFirstTimeInSelfCreatedGroup();
|
||||
withSearchOpen = args.isWithSearchOpen();
|
||||
giftBadge = args.getGiftBadge();
|
||||
shareDataTimestamp = args.getShareDataTimestamp();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder withDraftText(@Nullable String draftText) {
|
||||
this.draftText = draftText;
|
||||
return this;
|
||||
@@ -391,6 +282,26 @@ public class ConversationIntents {
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull ConversationArgs toConversationArgs() {
|
||||
return new ConversationArgs(
|
||||
recipientId,
|
||||
threadId,
|
||||
draftText,
|
||||
dataUri,
|
||||
dataType,
|
||||
media,
|
||||
stickerLocator,
|
||||
isBorderless,
|
||||
distributionType,
|
||||
startingPosition,
|
||||
firstTimeInSelfCreatedGroup,
|
||||
withSearchOpen,
|
||||
giftBadge,
|
||||
shareDataTimestamp,
|
||||
conversationScreenType
|
||||
);
|
||||
}
|
||||
|
||||
public @NonNull Intent build() {
|
||||
if (stickerLocator != null && media != null) {
|
||||
throw new IllegalStateException("Cannot have both sticker and media array");
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.components.location.SignalPlace
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
|
||||
import org.thoughtcrime.securesms.conversation.MessageStyler
|
||||
@@ -53,7 +53,7 @@ class DraftRepository(
|
||||
private val threadTable: ThreadTable = SignalDatabase.threads,
|
||||
private val draftTable: DraftTable = SignalDatabase.drafts,
|
||||
private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED),
|
||||
private val conversationArguments: ConversationIntents.Args? = null
|
||||
private val conversationArguments: ConversationArgs? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -154,6 +154,7 @@ import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity
|
||||
import org.thoughtcrime.securesms.conversation.AttachmentKeyboardButton
|
||||
import org.thoughtcrime.securesms.conversation.BadDecryptLearnMoreDialog
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs
|
||||
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
|
||||
import org.thoughtcrime.securesms.conversation.ConversationData
|
||||
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
|
||||
@@ -390,8 +391,8 @@ class ConversationFragment :
|
||||
private const val IS_SCROLLED_TO_BOTTOM_THRESHOLD: Int = 2
|
||||
}
|
||||
|
||||
private val args: ConversationIntents.Args by lazy {
|
||||
ConversationIntents.Args.from(requireArguments())
|
||||
private val args: ConversationArgs by lazy {
|
||||
ConversationIntents.readArgsFromBundle(requireArguments())
|
||||
}
|
||||
|
||||
private val conversationRecipientRepository: ConversationRecipientRepository by lazy {
|
||||
|
||||
@@ -9,7 +9,7 @@ import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs
|
||||
import org.thoughtcrime.securesms.util.delegate
|
||||
|
||||
/**
|
||||
@@ -33,7 +33,7 @@ class ShareDataTimestampViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun setTimestampFromConversationArgs(args: ConversationIntents.Args) {
|
||||
fun setTimestampFromConversationArgs(args: ConversationArgs) {
|
||||
timestamp = args.shareDataTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ package org.thoughtcrime.securesms.conversationlist;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.content.res.Configuration;
|
||||
import android.content.res.Resources;
|
||||
@@ -115,7 +114,7 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationUpdateTick;
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest;
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource;
|
||||
@@ -417,10 +416,9 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
lifecycleDisposable.add(mainNavigationViewModel.getDetailLocationObservable()
|
||||
.subscribeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(location -> {
|
||||
if (location instanceof MainNavigationDetailLocation.Conversation) {
|
||||
Intent intent = ((MainNavigationDetailLocation.Conversation) location).getIntent();
|
||||
ConversationIntents.Args args = ConversationIntents.Args.from(Objects.requireNonNull(intent.getExtras()));
|
||||
long threadId = args.getThreadId();
|
||||
if (location instanceof MainNavigationDetailLocation.Chats.Conversation) {
|
||||
ConversationArgs args = ((MainNavigationDetailLocation.Chats.Conversation) location).getConversationArgs();
|
||||
long threadId = args.threadId;
|
||||
|
||||
defaultAdapter.setActiveThreadId(threadId);
|
||||
}
|
||||
|
||||
@@ -37,8 +37,9 @@ fun Fragment.listenToEventBusWhileResumed(
|
||||
.collectLatest {
|
||||
if (resources.getWindowSizeClass().isCompact()) {
|
||||
when (it) {
|
||||
is MainNavigationDetailLocation.Conversation -> unsubscribe()
|
||||
is MainNavigationDetailLocation.Chats.Conversation -> unsubscribe()
|
||||
MainNavigationDetailLocation.Empty -> subscribe()
|
||||
else -> Unit
|
||||
}
|
||||
} else {
|
||||
subscribe()
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.toRoute
|
||||
import org.signal.core.ui.compose.Animations.navHostSlideInTransition
|
||||
import org.signal.core.ui.compose.Animations.navHostSlideOutTransition
|
||||
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameScreen
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsScreen
|
||||
import org.thoughtcrime.securesms.serialization.JsonSerializableNavType
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import kotlin.reflect.typeOf
|
||||
|
||||
/**
|
||||
* A navigation host for the calls detail pane of [org.thoughtcrime.securesms.MainActivity].
|
||||
*
|
||||
* @param currentDestination The current calls destination to navigate to, containing routing information
|
||||
* @param contentLayoutData Layout configuration data for responsive UI rendering
|
||||
*/
|
||||
@Composable
|
||||
fun CallsNavHost(
|
||||
currentDestination: MainNavigationDetailLocation.Calls,
|
||||
contentLayoutData: MainContentLayoutData
|
||||
) {
|
||||
val navHostController = key(currentDestination.controllerKey) {
|
||||
rememberNavController()
|
||||
}
|
||||
|
||||
val startDestination = remember(currentDestination.controllerKey) {
|
||||
MainNavigationDetailLocation.Calls.CallLinkDetails(currentDestination.controllerKey)
|
||||
}
|
||||
|
||||
LaunchedEffect(navHostController) {
|
||||
navHostController.enableOnBackPressed(true)
|
||||
}
|
||||
|
||||
LaunchedEffect(currentDestination) {
|
||||
if (currentDestination != startDestination) {
|
||||
navHostController.navigate(currentDestination)
|
||||
}
|
||||
}
|
||||
|
||||
val mainNavigationViewModel = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalContext.current as ComponentActivity) {
|
||||
error("Should already exist.")
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navHostController,
|
||||
startDestination = startDestination,
|
||||
enterTransition = { navHostSlideInTransition { it } },
|
||||
exitTransition = { navHostSlideOutTransition { -it } },
|
||||
popEnterTransition = { navHostSlideInTransition { -it } },
|
||||
popExitTransition = { navHostSlideOutTransition { it } },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
composable<MainNavigationDetailLocation.Calls.CallLinkDetails>(
|
||||
typeMap = mapOf(
|
||||
typeOf<CallLinkRoomId>() to JsonSerializableNavType(CallLinkRoomId.serializer())
|
||||
)
|
||||
) {
|
||||
val route = it.toRoute<MainNavigationDetailLocation.Calls.CallLinkDetails>()
|
||||
|
||||
LaunchedEffect(route) {
|
||||
mainNavigationViewModel.goTo(route)
|
||||
}
|
||||
|
||||
MainActivityDetailContainer(contentLayoutData) {
|
||||
CallLinkDetailsScreen(roomId = route.callLinkRoomId)
|
||||
}
|
||||
}
|
||||
|
||||
composable<MainNavigationDetailLocation.Calls.EditCallLinkName>(
|
||||
typeMap = mapOf(
|
||||
typeOf<CallLinkRoomId>() to JsonSerializableNavType(CallLinkRoomId.serializer())
|
||||
)
|
||||
) {
|
||||
val parent = navHostController.getBackStackEntry(startDestination)
|
||||
val route = it.toRoute<MainNavigationDetailLocation.Calls.EditCallLinkName>()
|
||||
|
||||
LaunchedEffect(route) {
|
||||
mainNavigationViewModel.goTo(route)
|
||||
}
|
||||
|
||||
MainActivityDetailContainer(contentLayoutData) {
|
||||
CompositionLocalProvider(LocalViewModelStoreOwner provides parent) {
|
||||
EditCallLinkNameScreen(roomId = route.callLinkRoomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.fragment.compose.AndroidFragment
|
||||
import androidx.fragment.compose.rememberFragmentState
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.toRoute
|
||||
import org.signal.core.ui.compose.Animations.navHostSlideInTransition
|
||||
import org.signal.core.ui.compose.Animations.navHostSlideOutTransition
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
|
||||
import org.thoughtcrime.securesms.serialization.JsonSerializableNavType
|
||||
import kotlin.reflect.typeOf
|
||||
|
||||
/**
|
||||
* A navigation host for the chats detail pane of [org.thoughtcrime.securesms.MainActivity].
|
||||
*
|
||||
* @param currentDestination The current calls destination to navigate to, containing routing information
|
||||
* @param contentLayoutData Layout configuration data for responsive UI rendering
|
||||
*/
|
||||
@Composable
|
||||
fun ChatsNavHost(
|
||||
currentDestination: MainNavigationDetailLocation.Chats,
|
||||
contentLayoutData: MainContentLayoutData
|
||||
) {
|
||||
val navHostController: NavHostController = key(currentDestination.controllerKey) {
|
||||
rememberNavController()
|
||||
}
|
||||
|
||||
val startDestination = remember(currentDestination.controllerKey) {
|
||||
currentDestination as? MainNavigationDetailLocation.Chats.Conversation ?: error("Unsupported start destination.")
|
||||
}
|
||||
|
||||
LaunchedEffect(currentDestination) {
|
||||
if (currentDestination != startDestination) {
|
||||
navHostController.navigate(currentDestination)
|
||||
}
|
||||
}
|
||||
|
||||
val mainNavigationViewModel = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalContext.current as ComponentActivity) {
|
||||
error("Should already exist.")
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navHostController,
|
||||
startDestination = startDestination,
|
||||
enterTransition = { navHostSlideInTransition { it } },
|
||||
exitTransition = { navHostSlideOutTransition { -it } },
|
||||
popEnterTransition = { navHostSlideInTransition { -it } },
|
||||
popExitTransition = { navHostSlideOutTransition { it } },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
composable<MainNavigationDetailLocation.Chats.Conversation>(
|
||||
typeMap = mapOf(
|
||||
typeOf<ConversationArgs>() to JsonSerializableNavType(ConversationArgs.serializer())
|
||||
)
|
||||
) {
|
||||
val route = it.toRoute<MainNavigationDetailLocation.Chats.Conversation>()
|
||||
val fragmentState = key(route) { rememberFragmentState() }
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(route) {
|
||||
mainNavigationViewModel.goTo(route)
|
||||
}
|
||||
|
||||
AndroidFragment(
|
||||
clazz = ConversationFragment::class.java,
|
||||
fragmentState = fragmentState,
|
||||
arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) { "Handed null Conversation intent arguments." },
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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.padding
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
@Composable
|
||||
fun EmptyDetailScreen(
|
||||
contentLayoutData: MainContentLayoutData
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_signal_logo_large),
|
||||
contentDescription = null,
|
||||
tint = Color(0x58607152),
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainActivityDetailContainer(
|
||||
contentLayoutData: MainContentLayoutData,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,59 @@
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
|
||||
/**
|
||||
* Describes which content to display in the detail view.
|
||||
*/
|
||||
@Parcelize
|
||||
sealed interface MainNavigationDetailLocation : Parcelable {
|
||||
@Serializable
|
||||
data object Empty : MainNavigationDetailLocation
|
||||
data class Conversation(val intent: Intent) : MainNavigationDetailLocation
|
||||
|
||||
@Parcelize
|
||||
sealed interface Chats : MainNavigationDetailLocation {
|
||||
|
||||
val controllerKey: RecipientId
|
||||
|
||||
@Serializable
|
||||
data class Conversation(val conversationArgs: ConversationArgs) : Chats {
|
||||
@Transient
|
||||
@IgnoredOnParcel
|
||||
override val controllerKey: RecipientId = conversationArgs.recipientId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content which can be displayed while the user is navigating the Calls tab.
|
||||
*/
|
||||
@Parcelize
|
||||
sealed interface Calls : MainNavigationDetailLocation {
|
||||
|
||||
val controllerKey: CallLinkRoomId
|
||||
|
||||
@Serializable
|
||||
data class CallLinkDetails(val callLinkRoomId: CallLinkRoomId) : Calls {
|
||||
@Transient
|
||||
@IgnoredOnParcel
|
||||
override val controllerKey: CallLinkRoomId = callLinkRoomId
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class EditCallLinkName(val callLinkRoomId: CallLinkRoomId) : Calls {
|
||||
@Transient
|
||||
@IgnoredOnParcel
|
||||
override val controllerKey: CallLinkRoomId = callLinkRoomId
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface Stories : MainNavigationDetailLocation
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
interface MainNavigationRouter {
|
||||
fun goTo(location: MainNavigationDetailLocation)
|
||||
|
||||
fun goTo(location: MainNavigationListLocation)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import org.thoughtcrime.securesms.stories.Stories
|
||||
class MainNavigationViewModel(
|
||||
initialListLocation: MainNavigationListLocation = MainNavigationListLocation.CHATS,
|
||||
initialDetailLocation: MainNavigationDetailLocation = MainNavigationDetailLocation.Empty
|
||||
) : ViewModel() {
|
||||
) : ViewModel(), MainNavigationRouter {
|
||||
private val megaphoneRepository = AppDependencies.megaphoneRepository
|
||||
|
||||
private var navigator: ThreePaneScaffoldNavigator<Any>? = null
|
||||
@@ -46,7 +46,8 @@ class MainNavigationViewModel(
|
||||
private val internalDetailLocation = MutableStateFlow(initialDetailLocation)
|
||||
val detailLocation: StateFlow<MainNavigationDetailLocation> = internalDetailLocation
|
||||
val detailLocationObservable: Observable<MainNavigationDetailLocation> = internalDetailLocation.asObservable()
|
||||
var latestConversationLocation: MainNavigationDetailLocation.Conversation? = null
|
||||
var latestConversationLocation: MainNavigationDetailLocation.Chats.Conversation? = null
|
||||
var latestCallsLocation: MainNavigationDetailLocation.Calls? = null
|
||||
|
||||
private val internalMegaphone = MutableStateFlow(Megaphone.NONE)
|
||||
val megaphone: StateFlow<Megaphone> = internalMegaphone
|
||||
@@ -117,7 +118,7 @@ class MainNavigationViewModel(
|
||||
* Navigates to the requested location. If the navigator is not present, this functionally sets our
|
||||
* "default" location to that specified, and we will route the user there when the navigator is set.
|
||||
*/
|
||||
fun goTo(location: MainNavigationDetailLocation) {
|
||||
override fun goTo(location: MainNavigationDetailLocation) {
|
||||
if (!SignalStore.internal.largeScreenUi) {
|
||||
goToLegacyDetailLocation?.invoke(location)
|
||||
return
|
||||
@@ -137,10 +138,15 @@ class MainNavigationViewModel(
|
||||
ThreePaneScaffoldRole.Secondary
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Conversation -> {
|
||||
is MainNavigationDetailLocation.Chats.Conversation -> {
|
||||
latestConversationLocation = location
|
||||
ThreePaneScaffoldRole.Primary
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls -> {
|
||||
latestCallsLocation = location
|
||||
ThreePaneScaffoldRole.Primary
|
||||
}
|
||||
}
|
||||
|
||||
navigatorScope?.launch {
|
||||
@@ -161,19 +167,28 @@ class MainNavigationViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun goTo(location: MainNavigationListLocation) {
|
||||
override fun goTo(location: MainNavigationListLocation) {
|
||||
if (navigator == null) {
|
||||
earlyNavigationListLocationRequested = location
|
||||
return
|
||||
}
|
||||
|
||||
if (location != MainNavigationListLocation.CHATS) {
|
||||
internalDetailLocation.update {
|
||||
MainNavigationDetailLocation.Empty
|
||||
when (location) {
|
||||
MainNavigationListLocation.CHATS -> {
|
||||
internalDetailLocation.update {
|
||||
latestConversationLocation ?: MainNavigationDetailLocation.Empty
|
||||
}
|
||||
}
|
||||
} else {
|
||||
internalDetailLocation.update {
|
||||
latestConversationLocation ?: MainNavigationDetailLocation.Empty
|
||||
MainNavigationListLocation.ARCHIVE -> Unit
|
||||
MainNavigationListLocation.CALLS -> {
|
||||
internalDetailLocation.update {
|
||||
latestCallsLocation ?: MainNavigationDetailLocation.Empty
|
||||
}
|
||||
}
|
||||
MainNavigationListLocation.STORIES -> {
|
||||
internalDetailLocation.update {
|
||||
MainNavigationDetailLocation.Empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import org.signal.core.ui.compose.Animations.navHostSlideInTransition
|
||||
import org.signal.core.ui.compose.Animations.navHostSlideOutTransition
|
||||
|
||||
/**
|
||||
* A navigation host for the stories detail pane of [org.thoughtcrime.securesms.MainActivity].
|
||||
*
|
||||
* @param currentDestination The current calls destination to navigate to, containing routing information
|
||||
* @param contentLayoutData Layout configuration data for responsive UI rendering
|
||||
*/
|
||||
@Composable
|
||||
fun StoriesNavHost(
|
||||
navHostController: NavHostController,
|
||||
startDestination: MainNavigationDetailLocation.Stories,
|
||||
contentLayoutData: MainContentLayoutData
|
||||
) {
|
||||
NavHost(
|
||||
navController = navHostController,
|
||||
startDestination = startDestination,
|
||||
enterTransition = { navHostSlideInTransition { it } },
|
||||
exitTransition = { navHostSlideOutTransition { -it } },
|
||||
popEnterTransition = { navHostSlideInTransition { -it } },
|
||||
popExitTransition = { navHostSlideOutTransition { it } }
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.service.webrtc.links
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Base64
|
||||
@@ -14,6 +15,7 @@ import org.signal.core.util.Hex
|
||||
import org.signal.core.util.Serializer
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
class CallLinkRoomId private constructor(private val roomId: ByteArray) : Parcelable {
|
||||
fun serialize(): String = DatabaseSerializer.serialize(this)
|
||||
|
||||
@@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.stickers
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
class StickerLocator(
|
||||
@JvmField
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/call_link_details"
|
||||
app:startDestination="@id/callLinkDetailsFragment">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/callLinkDetailsFragment"
|
||||
android:name="org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsFragment"
|
||||
android:label="call_link_details">
|
||||
<action
|
||||
android:id="@+id/action_callLinkDetailsFragment_to_editCallLinkNameDialogFragment"
|
||||
app:destination="@id/editCallLinkNameDialogFragment"
|
||||
app:enterAnim="@anim/fragment_open_enter"
|
||||
app:exitAnim="@anim/fragment_close_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit" />
|
||||
|
||||
<argument
|
||||
android:name="room_id"
|
||||
app:argType="org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId"
|
||||
app:nullable="false" />
|
||||
</fragment>
|
||||
|
||||
<dialog
|
||||
android:id="@+id/editCallLinkNameDialogFragment"
|
||||
android:name="org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment"
|
||||
android:label="edit_call_link_name_dialog">
|
||||
<argument
|
||||
android:name="name"
|
||||
app:argType="string"
|
||||
app:nullable="false" />
|
||||
</dialog>
|
||||
|
||||
</navigation>
|
||||
@@ -42,8 +42,8 @@ object Scaffolds {
|
||||
fun Settings(
|
||||
title: String,
|
||||
onNavigationClick: () -> Unit,
|
||||
navigationIcon: ImageVector,
|
||||
modifier: Modifier = Modifier,
|
||||
navigationIcon: ImageVector? = null,
|
||||
navigationContentDescription: String? = null,
|
||||
titleContent: @Composable (Float, String) -> Unit = { _, title ->
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge)
|
||||
@@ -80,7 +80,7 @@ object Scaffolds {
|
||||
title: String,
|
||||
titleContent: @Composable (Float, String) -> Unit,
|
||||
onNavigationClick: () -> Unit,
|
||||
navigationIcon: ImageVector,
|
||||
navigationIcon: ImageVector?,
|
||||
navigationContentDescription: String? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
@@ -90,14 +90,16 @@ object Scaffolds {
|
||||
titleContent(scrollBehavior.state.contentOffset, title)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onNavigationClick,
|
||||
Modifier.padding(end = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = navigationIcon,
|
||||
contentDescription = navigationContentDescription
|
||||
)
|
||||
if (navigationIcon != null) {
|
||||
IconButton(
|
||||
onClick = onNavigationClick,
|
||||
Modifier.padding(end = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = navigationIcon,
|
||||
contentDescription = navigationContentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
@@ -134,3 +136,28 @@ private fun SettingsScaffoldPreview() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SettingsScaffoldNoNavIconPreview() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Scaffolds.Settings(
|
||||
"Settings Scaffold",
|
||||
onNavigationClick = {},
|
||||
actions = {
|
||||
IconButton(onClick = {}) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Text("Content")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user