Migrate MultiselectForward to Jetpack Compose.

This commit is contained in:
Alex Hart
2026-06-09 17:21:48 -04:00
committed by Cody Henthorne
parent 9478cdf049
commit 5909a1b92a
25 changed files with 900 additions and 497 deletions
@@ -4,7 +4,6 @@ import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.navigation.fragment.findNavController
@@ -19,29 +18,25 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigur
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.util.activityViewModel
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
import kotlin.getValue
/**
* Allows the user to select a recipient to send a gift to.
*/
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient_selection_fragment), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.multiselect_forward_activity), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
private val viewModel: GiftFlowViewModel by activityViewModel {
GiftFlowViewModel()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
if (savedInstanceState == null) {
childFragmentManager.beginTransaction()
.replace(
R.id.multiselect_container,
R.id.fragment_container,
MultiselectForwardFragment.create(
MultiselectForwardFragmentArgs(
multiShareArgs = emptyList(),
title = R.string.GiftFlowRecipientSelectionFragment__choose_recipient,
forceDisableAddMessage = true,
selectSingleRecipient = true
)
@@ -79,6 +74,10 @@ class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient
override fun exitFlow() = Unit
override fun navigateUp() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun onSearchInputFocused() = Unit
override fun setResult(bundle: Bundle) {
@@ -94,6 +94,8 @@ class ContactSearchViewModel(
val isDisplayingContextMenu: StateFlow<Boolean> = internalDisplayingContextMenu
val scrollRequests: SharedFlow<ScrollRequest> = internalScrollRequests
val query: StateFlow<String?> = rawQuery
init {
viewModelScope.launch {
rawQuery.drop(1).debounce(300).collect { query ->
@@ -157,8 +159,6 @@ class ContactSearchViewModel(
pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig)
}
fun getQuery(): String? = rawQuery.value
fun setQuery(query: String?) {
rawQuery.value = query
}
@@ -23,6 +23,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
@@ -50,7 +51,8 @@ fun RecipientSearchBar(
onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
modifier: Modifier = Modifier,
enabledKeyboardTypes: List<KeyboardType> = listOf(KeyboardType.Text, KeyboardType.Phone)
enabledKeyboardTypes: List<KeyboardType> = listOf(KeyboardType.Text, KeyboardType.Phone),
onFocusChanged: (Boolean) -> Unit = {}
) {
val state = rememberSearchBarState()
var keyboardType by remember(enabledKeyboardTypes) { mutableStateOf(enabledKeyboardTypes.first()) }
@@ -67,6 +69,7 @@ fun RecipientSearchBar(
TextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.onFocusChanged { onFocusChanged(it.isFocused) },
placeholder = { Text(hint) },
singleLine = true,
textStyle = TextStyle(textDirection = TextDirection.ContentOrLtr),
@@ -5,7 +5,6 @@ import android.content.Intent
import android.os.Bundle
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContract
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
@@ -29,14 +28,6 @@ open class MultiselectForwardActivity : FragmentWrapperActivity(), MultiselectFo
override val contentViewId: Int = R.layout.multiselect_forward_activity
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
val toolbar: Toolbar = findViewById(R.id.toolbar)
toolbar.setTitle(args.title)
toolbar.setNavigationOnClickListener { exitFlow() }
}
override fun getFragment(): Fragment {
return MultiselectForwardFragment.create(args)
}
@@ -0,0 +1,303 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.TextFields
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.isSplitPane
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.rememberRecipientField
@Composable
fun MultiselectForwardBottomBar(
isSplitPane: Boolean,
state: MultiselectForwardBottomBarState,
events: (MultiselectForwardBottomBarEvent) -> Unit,
modifier: Modifier = Modifier
) {
val gutter = dimensionResource(org.signal.core.ui.R.dimen.gutter)
Column(
modifier = modifier
) {
HorizontalDivider(
modifier = Modifier.padding(bottom = 12.dp)
)
Row {
if (isSplitPane) {
Spacer(modifier = Modifier.weight(1f))
}
Column(
modifier = Modifier.weight(1f)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
val shouldDisplayAddMessageInThisRow = state.isSendButtonVisible && !state.isAddMessageVisible
Selection(
state,
modifier = Modifier
.weight(1f)
.heightIn(min = 44.dp)
.padding(end = if (shouldDisplayAddMessageInThisRow) 8.dp else gutter)
)
if (shouldDisplayAddMessageInThisRow) {
Send(
state = state,
events = events,
modifier = Modifier.padding(end = gutter)
)
}
}
if (state.isAddMessageVisible) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(8.dp),
modifier = Modifier
.heightIn(min = 44.dp)
.horizontalGutters()
) {
AddMessage(
state = state,
events = events,
modifier = Modifier
.weight(1f)
.heightIn(min = 44.dp)
)
if (state.isSendButtonVisible) {
Send(
state = state,
events = events
)
}
}
}
}
}
}
}
@Composable
private fun Selection(
state: MultiselectForwardBottomBarState,
modifier: Modifier = Modifier
) {
LazyRow(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
itemsIndexed(items = state.selection, key = { _, contact -> contact.key }) { index, contact ->
val name by rememberDisplayName(contact)
Text(
text = "$name${if (index != state.selection.lastIndex) ", " else " "}",
modifier = Modifier.padding(start = if (index == 0) dimensionResource(org.signal.core.ui.R.dimen.gutter) else 0.dp)
)
}
}
}
@Composable
private fun AddMessage(
state: MultiselectForwardBottomBarState,
events: (MultiselectForwardBottomBarEvent) -> Unit,
modifier: Modifier = Modifier
) {
TextFields.TextField(
value = state.message,
onValueChange = { events(MultiselectForwardBottomBarEvent.AddMessageUpdate(it)) },
placeholder = {
Text(text = stringResource(R.string.MultiselectForwardFragment__add_a_message))
},
shape = RoundedCornerShape(50),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent
),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 10.dp,
bottom = 10.dp
),
modifier = modifier
)
}
@Composable
private fun Send(
state: MultiselectForwardBottomBarState,
events: (MultiselectForwardBottomBarEvent) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val background = remember(context) { state.sendButtonColors.background.resolve(context) }
val foreground = remember(context) { state.sendButtonColors.foreground.resolve(context) }
IconButtons.IconButton(
enabled = state.isSendButtonEnabled,
onClick = { events(MultiselectForwardBottomBarEvent.SendClick) },
modifier = modifier,
colors = IconButtons.iconButtonColors(
contentColor = Color(foreground),
containerColor = Color(background)
)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_send_fill_24),
contentDescription = stringResource(R.string.ShareActivity__send)
)
}
}
@Composable
private fun rememberDisplayName(contact: MultiselectForwardBottomBarState.SelectedContact): State<String> = when (contact) {
is MultiselectForwardBottomBarState.SelectedContact.KnownRecipient -> {
val context = LocalContext.current
if (contact.recipient.isSelf) {
val noteToSelf = stringResource(R.string.note_to_self)
rememberUpdatedState(noteToSelf)
} else {
rememberRecipientField(contact.recipient) { getShortDisplayName(context) }
}
}
is MultiselectForwardBottomBarState.SelectedContact.UnknownRecipient -> rememberUpdatedState(contact.e164)
}
@DayNightPreviews
@Composable
private fun MultiselectForwardBottomBarPreview() {
Previews.Preview {
MultiselectForwardBottomBar(
isSplitPane = false,
state = rememberPreviewState(),
events = {},
modifier = Modifier.fillMaxWidth()
)
}
}
@DayNightPreviews
@Composable
private fun MultiselectForwardBottomBarPreviewWithSend() {
Previews.Preview {
MultiselectForwardBottomBar(
isSplitPane = false,
state = rememberPreviewState(
isSendButtonVisible = true
),
events = {},
modifier = Modifier.fillMaxWidth()
)
}
}
@DayNightPreviews
@Composable
private fun MultiselectForwardBottomBarPreviewWithAddMessage() {
Previews.Preview {
MultiselectForwardBottomBar(
isSplitPane = false,
state = rememberPreviewState(
isAddMessageVisible = true
),
events = {},
modifier = Modifier.fillMaxWidth()
)
}
}
@DayNightPreviews
@Composable
private fun MultiselectForwardBottomBarPreviewWithBoth() {
Previews.Preview {
MultiselectForwardBottomBar(
isSplitPane = false,
state = rememberPreviewState(
isSendButtonVisible = true,
isAddMessageVisible = true
),
events = {},
modifier = Modifier.fillMaxWidth()
)
}
}
@DayNightPreviews
@Composable
private fun MultiselectForwardBottomBarPreviewWithSplit() {
Previews.Preview {
MultiselectForwardBottomBar(
isSplitPane = true,
state = rememberPreviewState(
isSendButtonVisible = true,
isAddMessageVisible = true
),
events = {},
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun rememberPreviewState(
isSendButtonVisible: Boolean = false,
isAddMessageVisible: Boolean = false
): MultiselectForwardBottomBarState {
return remember {
MultiselectForwardBottomBarState(
selection = listOf(
MultiselectForwardBottomBarState.SelectedContact.KnownRecipient(Recipient(id = RecipientId.from(1), isResolving = false, systemContactName = "Miles")),
MultiselectForwardBottomBarState.SelectedContact.KnownRecipient(Recipient(id = RecipientId.from(2), isResolving = false, systemContactName = "Peter")),
MultiselectForwardBottomBarState.SelectedContact.KnownRecipient(Recipient(id = RecipientId.from(3), isResolving = false, systemContactName = "May"))
),
isSendButtonVisible = isSendButtonVisible,
isAddMessageVisible = isAddMessageVisible
)
}
}
@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.mutiselect.forward
sealed interface MultiselectForwardBottomBarEvent {
data class AddMessageUpdate(val message: String) : MultiselectForwardBottomBarEvent
data object SendClick : MultiselectForwardBottomBarEvent
}
@@ -0,0 +1,32 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import androidx.compose.runtime.annotation.RememberInComposition
import org.thoughtcrime.securesms.color.ViewColorSet
import org.thoughtcrime.securesms.recipients.Recipient
data class MultiselectForwardBottomBarState @RememberInComposition constructor(
val selection: List<SelectedContact> = emptyList(),
val message: String = "",
val sendButtonColors: ViewColorSet = ViewColorSet.PRIMARY,
val isSendButtonEnabled: Boolean = true,
val isSendButtonVisible: Boolean = false,
val isAddMessageVisible: Boolean = false
) {
sealed interface SelectedContact {
val key: String
data class KnownRecipient(val recipient: Recipient) : SelectedContact {
override val key: String = recipient.id.toString()
}
data class UnknownRecipient(val e164: String) : SelectedContact {
override val key: String = e164
}
}
}
@@ -1,24 +1,38 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.content.res.ColorStateList
import android.graphics.Rect
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.LayoutInflater
import android.view.TouchDelegate
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.animation.AnimationUtils
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.os.bundleOf
import androidx.core.view.ViewCompat
import androidx.core.view.doOnNextLayout
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
@@ -26,23 +40,25 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.collections.immutable.persistentHashMapOf
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.LocalFragmentManager
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableArrayListCompat
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.color.ViewColorSet
import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.components.TooltipPopup
import org.thoughtcrime.securesms.components.WrapperDialogFragment
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchCallbacks
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchError
@@ -50,29 +66,27 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter
import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel
import org.thoughtcrime.securesms.stories.GroupStoryEducationSheet
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.Stories.getHeaderAction
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment
import org.thoughtcrime.securesms.stories.settings.privacy.ChooseInitialMyStoryMembershipBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.FullscreenHelper
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.fragments.findListener
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
import org.thoughtcrime.securesms.util.visible
@@ -91,38 +105,35 @@ import org.thoughtcrime.securesms.util.visible
* It is up to the user of this fragment to handle the result accordingly utilizing a fragment result listener.
*/
class MultiselectForwardFragment :
Fragment(R.layout.multiselect_forward_fragment),
ComposeFragment(),
SafetyNumberBottomSheet.Callbacks,
ChooseStoryTypeBottomSheet.Callback,
GroupStoryEducationSheet.Callback,
WrapperDialogFragment.WrapperDialogFragmentCallback,
ChooseInitialMyStoryMembershipBottomSheetDialogFragment.Callback {
private val viewModel: MultiselectForwardViewModel by viewModels(factoryProducer = this::createViewModelFactory)
private val viewModel: MultiselectForwardViewModel by viewModel {
MultiselectForwardViewModel(args)
}
private val contactSearchViewModel: ContactSearchViewModel by viewModels {
ContactSearchViewModel.Factory(
selectionLimits = RemoteConfig.shareSelectionLimit,
isMultiSelect = !args.selectSingleRecipient,
repository = ContactSearchRepository(),
performSafetyNumberChecks = true,
arbitraryRepository = null,
arbitraryRepository = findListener<SearchConfigurationProvider>()?.getArbitraryRepository(),
searchRepository = SearchRepository(requireContext().getString(R.string.note_to_self)),
contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(requireContext())
)
}
private val disposables = LifecycleDisposable()
private lateinit var contactFilterView: ContactFilterView
private lateinit var addMessage: EditText
private lateinit var contactSearch: ContactSearchView
private lateinit var callback: Callback
private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null
private var handler: Handler? = null
private fun createViewModelFactory(): MultiselectForwardViewModel.Factory {
return MultiselectForwardViewModel.Factory(args.storySendRequirements, args.multiShareArgs, args.forceSelectionOnly, MultiselectForwardRepository())
}
private var bottomBarHeightPx by mutableIntStateOf(0)
private val args: MultiselectForwardFragmentArgs by lazy {
requireArguments().getParcelableCompat(ARGS, MultiselectForwardFragmentArgs::class.java)!!
@@ -136,105 +147,109 @@ class MultiselectForwardFragment :
}
}
@Composable
override fun FragmentContent() {
CompositionLocalProvider(LocalFragmentManager provides childFragmentManager) {
MultiselectForwardScreen(
isSplitPane = !args.isWrappedInBottomSheet && LocalResources.current.rememberIsSplitPane(),
args = args,
contactSearchViewModel = contactSearchViewModel,
callback = callback,
mapStateToConfiguration = this@MultiselectForwardFragment::getConfiguration,
contactSearchCallbacks = remember { SearchCallbacks() },
additionalEntries = findListener<SearchConfigurationProvider>()?.getAdditionalEntries() ?: persistentHashMapOf(),
bottomContentPadding = with(LocalDensity.current) { bottomBarHeightPx.toDp() }
)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
Log.d(TAG, "onViewCreated()")
view.minimumHeight = resources.displayMetrics.heightPixels
contactSearch = view.findViewById(R.id.contact_selection_list)
contactSearch.bind(
viewModel = contactSearchViewModel,
fragmentManager = childFragmentManager,
displayOptions = ContactSearchAdapter.DisplayOptions(
displayCheckBox = !args.selectSingleRecipient,
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER,
displayStoryRing = true
),
mapStateToConfiguration = this::getConfiguration,
callbacks = object : ContactSearchCallbacks.Simple() {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
val filtered: Set<ContactSearchKey> = filterContacts(view, contactSearchKeys)
Log.d(TAG, "onBeforeContactsSelected() Attempting to select: ${contactSearchKeys.map { it.toString() }}, Filtered selection: ${filtered.map { it.toString() } }")
return filtered
}
}
)
callback = findListener()!!
callback = requireListener()
disposables.bindTo(viewLifecycleOwner.lifecycle)
contactFilterView = view.findViewById(R.id.contact_filter_edit_text)
contactFilterView.visible = args.isSearchEnabled
contactFilterView.setOnSearchInputFocusChangedListener { _, hasFocus ->
if (hasFocus) {
callback.onSearchInputFocused()
}
}
contactFilterView.setOnFilterChangedListener {
contactSearchViewModel.setQuery(it)
}
val container = callback.getContainer()
val title: TextView? = container.findViewById(R.id.title)
val bottomBarAndSpacer = LayoutInflater.from(requireContext()).inflate(R.layout.multiselect_forward_fragment_bottom_bar_and_spacer, container, false)
val bottomBar: ViewGroup = bottomBarAndSpacer.findViewById(R.id.bottom_bar)
val bottomBarSpacer: View = bottomBarAndSpacer.findViewById(R.id.bottom_bar_spacer)
val shareSelectionRecycler: RecyclerView = bottomBar.findViewById(R.id.selected_list)
val shareSelectionAdapter = ShareSelectionAdapter()
val sendButtonFrame: View = bottomBar.findViewById(R.id.share_confirm_frame)
val sendButton: AppCompatImageView = bottomBar.findViewById(R.id.share_confirm)
val backgroundHelper: View = bottomBar.findViewById(R.id.background_helper)
val bottomBar = ComposeView(requireContext())
bottomBar.layoutParams = when (container) {
is CoordinatorLayout -> CoordinatorLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { gravity = Gravity.BOTTOM }
is FrameLayout -> FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT, Gravity.BOTTOM)
else -> ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
}
bottomBar.setContent {
val state by viewModel.bottomBarState.collectAsStateWithLifecycle()
val imeController = LocalSoftwareKeyboardController.current
val isSplitPane = !args.isWrappedInBottomSheet && LocalResources.current.rememberIsSplitPane()
val sendButtonColors: ViewColorSet = args.sendButtonColors
sendButton.setColorFilter(sendButtonColors.foreground.resolve(requireContext()))
ViewCompat.setBackgroundTintList(sendButton, ColorStateList.valueOf(sendButtonColors.background.resolve(requireContext())))
FullscreenHelper.configureBottomBarLayout(requireActivity(), bottomBarSpacer, bottomBar)
bottomBar.setOnTouchListener { _, _ -> true }
backgroundHelper.setBackgroundColor(callback.getDialogBackgroundColor())
bottomBarSpacer.setBackgroundColor(callback.getDialogBackgroundColor())
title?.setText(args.title)
addMessage = bottomBar.findViewById(R.id.add_message)
sendButton.doOnNextLayout {
val rect = Rect()
sendButton.getHitRect(rect)
rect.top -= sendButtonFrame.paddingTop
rect.left -= sendButtonFrame.paddingStart
rect.right += sendButtonFrame.paddingEnd
rect.bottom += sendButtonFrame.paddingBottom
sendButtonFrame.touchDelegate = TouchDelegate(rect, sendButton)
SignalTheme {
Surface(
color = remember { Color(callback.getDialogBackgroundColor()) },
// Swallow touches that miss the bar's children so they don't toggle a contact row behind it.
modifier = Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent().changes.forEach { it.consume() }
}
}
}
) {
MultiselectForwardBottomBar(
state = state,
events = {
when (it) {
is MultiselectForwardBottomBarEvent.AddMessageUpdate -> {
viewModel.setMessage(it.message)
}
MultiselectForwardBottomBarEvent.SendClick -> {
imeController?.hide()
onSend()
}
}
},
isSplitPane = isSplitPane,
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
)
}
}
}
sendButton.setOnClickListener {
ViewUtil.hideKeyboard(requireContext(), it)
onSend(it)
if (args.isWrappedInBottomSheet) {
title?.setText(args.title)
}
sendButton.visible = !args.selectSingleRecipient
shareSelectionRecycler.adapter = shareSelectionAdapter
bottomBar.visible = false
container.addView(bottomBarAndSpacer)
bottomBar.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
bottomBarHeightPx = if (bottomBar.isVisible) bottom - top else 0
}
container.addView(bottomBar)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
contactSearchViewModel.selectionState.collect { contactSelection ->
if (contactSelection.isNotEmpty() && args.selectSingleRecipient) {
onSend(sendButton)
onSend()
return@collect
}
shareSelectionAdapter.submitList(contactSelection.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) })
viewModel.setShareSelection(
contactSelection.map { key ->
val contact = key.requireShareContact()
contact.recipientId
.map<MultiselectForwardBottomBarState.SelectedContact> { MultiselectForwardBottomBarState.SelectedContact.KnownRecipient(Recipient.resolved(it)) }
.orElseGet {
MultiselectForwardBottomBarState.SelectedContact.UnknownRecipient(contact.number)
}
}
)
addMessage.visible = !args.forceDisableAddMessage && contactSelection.any { key -> !key.requireRecipientSearchKey().isStory } && args.multiShareArgs.isNotEmpty()
viewModel.setAddMessageVisible(!args.forceDisableAddMessage && contactSelection.any { key -> !key.requireRecipientSearchKey().isStory } && args.multiShareArgs.isNotEmpty())
if (contactSelection.isNotEmpty() && !bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_from_bottom)
@@ -245,6 +260,7 @@ class MultiselectForwardFragment :
} else if (contactSelection.isEmpty() && bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_to_bottom)
bottomBar.visible = false
bottomBarHeightPx = 0
}
}
}
@@ -277,19 +293,20 @@ class MultiselectForwardFragment :
dismissibleDialog?.dismiss()
dismissibleDialog = SimpleProgressDialog.showDelayed(requireContext())
}
MultiselectForwardState.Stage.SomeFailed -> dismissWithSuccess(R.plurals.MultiselectForwardFragment_messages_sent)
MultiselectForwardState.Stage.AllFailed -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_failed_to_send)
MultiselectForwardState.Stage.Success -> dismissWithSuccess(R.plurals.MultiselectForwardFragment_messages_sent)
is MultiselectForwardState.Stage.SelectionConfirmed -> dismissWithSelection(it.stage.selectedContacts)
}
sendButton.isEnabled = it.stage == MultiselectForwardState.Stage.Selection
viewModel.setSendEnabled(it.stage == MultiselectForwardState.Stage.Selection)
}
setFragmentResultListener(CreateStoryWithViewersFragment.REQUEST_KEY) { _, bundle ->
val recipientId: RecipientId = bundle.getParcelableCompat(CreateStoryWithViewersFragment.STORY_RECIPIENT, RecipientId::class.java)!!
contactSearchViewModel.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey(recipientId, true)))
contactFilterView.clear()
contactSearchViewModel.setQuery(null)
}
setFragmentResultListener(ChooseGroupStoryBottomSheet.GROUP_STORY) { _, bundle ->
@@ -297,7 +314,7 @@ class MultiselectForwardFragment :
val keys: Set<ContactSearchKey.RecipientSearchKey> = groups.map { ContactSearchKey.RecipientSearchKey(it, true) }.toSet()
contactSearchViewModel.addToVisibleGroupStories(keys)
contactSearchViewModel.setKeysSelected(keys)
contactFilterView.clear()
contactSearchViewModel.setQuery(null)
}
}
@@ -344,7 +361,7 @@ class MultiselectForwardFragment :
.setMessage(R.string.MultiselectForwardFragment__forwarded_messages_are_now)
.setPositiveButton(resources.getQuantityString(R.plurals.MultiselectForwardFragment_send_d_messages, messageCount, messageCount)) { d, _ ->
d.dismiss()
viewModel.confirmFirstSend(addMessage.text.toString(), contactSearchViewModel.getSelectedContacts())
viewModel.confirmFirstSend(contactSearchViewModel.getSelectedContacts())
}
.setNegativeButton(android.R.string.cancel) { d, _ ->
d.dismiss()
@@ -353,9 +370,9 @@ class MultiselectForwardFragment :
.show()
}
private fun onSend(sendButton: View) {
sendButton.isEnabled = false
viewModel.send(addMessage.text.toString(), contactSearchViewModel.getSelectedContacts())
private fun onSend() {
viewModel.setSendEnabled(false)
viewModel.send(contactSearchViewModel.getSelectedContacts())
}
private fun displaySafetyNumberConfirmation(identityRecords: List<IdentityRecord>, selectedContacts: List<ContactSearchKey>) {
@@ -387,7 +404,7 @@ class MultiselectForwardFragment :
callback.exitFlow()
}
private fun getMessageCount(): Int = args.multiShareArgs.size + if (addMessage.text.isNotEmpty()) 1 else 0
private fun getMessageCount(): Int = args.multiShareArgs.size + if (viewModel.bottomBarState.value.message.isNotEmpty()) 1 else 0
private fun handleMessageExpired() {
Log.d(TAG, "handleMessageExpired")
@@ -413,7 +430,7 @@ class MultiselectForwardFragment :
}
override fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List<ContactSearchKey.RecipientSearchKey>) {
viewModel.confirmSafetySend(addMessage.text.toString(), destinations.toSet())
viewModel.confirmSafetySend(destinations.toSet())
}
override fun onMessageResentAfterSafetyNumberChangeInBottomSheet() {
@@ -439,9 +456,11 @@ class MultiselectForwardFragment :
Stories.MediaTransform.SendRequirements.REQUIRES_CLIP -> {
displayTooltip(view, R.string.MultiselectForwardFragment__videos_will_be_trimmed)
}
Stories.MediaTransform.SendRequirements.CAN_NOT_SEND -> {
displayTooltip(view, R.string.MultiselectForwardFragment__videos_sent_to_stories_cant)
}
Stories.MediaTransform.SendRequirements.VALID_DURATION -> Unit
}
}
@@ -551,6 +570,14 @@ class MultiselectForwardFragment :
contactSearchViewModel.refresh()
}
private inner class SearchCallbacks : ContactSearchCallbacks.Simple() {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
val filtered: Set<ContactSearchKey> = filterContacts(view, contactSearchKeys)
Log.d(TAG, "onBeforeContactsSelected() Attempting to select: ${contactSearchKeys.map { it.toString() }}, Filtered selection: ${filtered.map { it.toString() }}")
return filtered
}
}
interface Callback {
fun onFinishForwardAction()
fun exitFlow()
@@ -559,12 +586,16 @@ class MultiselectForwardFragment :
fun getContainer(): ViewGroup
fun getDialogBackgroundColor(): Int
fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? = null
/**
* Called when the user presses the navigation icon in the toolbar. Defaults to exitFlow().
*/
fun navigateUp() = exitFlow()
}
companion object {
private val TAG = Log.tag(MultiselectForwardActivity::class.java)
const val DIALOG_TITLE = "title"
const val ARGS = "args"
const val RESULT_KEY = "result_key"
const val RESULT_SELECTION = "result_selection_recipients"
@@ -592,7 +623,7 @@ class MultiselectForwardFragment :
}
private fun showDialogFragment(supportFragmentManager: FragmentManager, fragment: DialogFragment, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) {
fragment.arguments = bundleOf(ARGS to multiselectForwardFragmentArgs, DIALOG_TITLE to multiselectForwardFragmentArgs.title)
fragment.arguments = bundleOf(ARGS to multiselectForwardFragmentArgs)
fragment.show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
@@ -6,6 +6,7 @@ import android.os.Parcelable
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.annotation.WorkerThread
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.signal.core.models.media.Media
import org.signal.core.util.StreamUtil
@@ -42,6 +43,7 @@ data class MultiselectForwardFragmentArgs @JvmOverloads constructor(
@StringRes val title: Int = R.string.MultiselectForwardFragment__forward_to,
val forceDisableAddMessage: Boolean = false,
val forceSelectionOnly: Boolean = false,
val forceHideToolbar: Boolean = false,
val selectSingleRecipient: Boolean = false,
val sendButtonColors: ViewColorSet = ViewColorSet.PRIMARY,
val storySendRequirements: Stories.MediaTransform.SendRequirements = Stories.MediaTransform.SendRequirements.CAN_NOT_SEND,
@@ -50,6 +52,9 @@ data class MultiselectForwardFragmentArgs @JvmOverloads constructor(
val isWrappedInBottomSheet: Boolean = false
) : Parcelable {
@IgnoredOnParcel
val isToolbarVisible: Boolean = !forceHideToolbar && !isWrappedInBottomSheet
fun withSendButtonTint(@ColorInt sendButtonTint: Int) = copy(sendButtonColors = ViewColorSet.forCustomColor(sendButtonTint))
companion object {
@@ -1,25 +1,32 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FullScreenDialogFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment.Companion.DIALOG_TITLE
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.fragments.findListener
class MultiselectForwardFullScreenDialogFragment : FullScreenDialogFragment(), MultiselectForwardFragment.Callback {
override fun getTitle(): Int = requireArguments().getInt(DIALOG_TITLE)
class MultiselectForwardFullScreenDialogFragment : DialogFragment(), MultiselectForwardFragment.Callback {
override fun getDialogLayoutResource(): Int = R.layout.fragment_container
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
override fun onFinishForwardAction() {
findListener<Callback>()?.onFinishForwardAction()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.multiselect_forward_activity, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (savedInstanceState == null) {
val fragment = MultiselectForwardFragment()
@@ -40,7 +47,7 @@ class MultiselectForwardFullScreenDialogFragment : FullScreenDialogFragment(), M
}
override fun getContainer(): ViewGroup {
return requireView().findViewById(R.id.full_screen_dialog_content) as ViewGroup
return requireView().findViewById(R.id.fragment_container_wrapper)!!
}
override fun setResult(bundle: Bundle) {
@@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.sharing.MultiShareSender
import org.thoughtcrime.securesms.stories.Stories
import java.util.Optional
class MultiselectForwardRepository {
object MultiselectForwardRepository {
class MultiselectForwardResultHandlers(
val onAllMessageSentSuccessfully: () -> Unit,
@@ -0,0 +1,178 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.collections.immutable.persistentHashMapOf
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearch
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchCallbacks
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel
import org.thoughtcrime.securesms.conversation.RecipientSearchBar
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MultiselectForwardScreen(
isSplitPane: Boolean,
args: MultiselectForwardFragmentArgs,
contactSearchViewModel: ContactSearchViewModel,
callback: MultiselectForwardFragment.Callback,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
additionalEntries: MappingEntryProvider<Any> = persistentHashMapOf(),
contactSearchCallbacks: ContactSearchCallbacks,
bottomContentPadding: Dp = 0.dp
) {
if (args.isWrappedInBottomSheet) {
MultiselectForwardContent(
isSplitPane = isSplitPane,
args = args,
contactSearchViewModel = contactSearchViewModel,
callback = callback,
mapStateToConfiguration = mapStateToConfiguration,
contactSearchCallbacks = contactSearchCallbacks,
additionalEntries = additionalEntries,
bottomContentPadding = bottomContentPadding
)
} else {
Scaffold(
topBar = {
if (args.isToolbarVisible) {
TopAppBar(
title = {
if (!isSplitPane) {
Text(text = stringResource(args.title))
}
},
navigationIcon = {
IconButton(
onClick = {
callback.navigateUp()
}
) {
Icon(
imageVector = SignalIcons.ArrowStart.imageVector,
contentDescription = stringResource(R.string.DSLSettingsToolbar__navigate_up)
)
}
}
)
}
}
) {
MultiselectForwardContent(
isSplitPane = isSplitPane,
args = args,
contactSearchViewModel = contactSearchViewModel,
callback = callback,
mapStateToConfiguration = mapStateToConfiguration,
contactSearchCallbacks = contactSearchCallbacks,
modifier = Modifier.padding(it),
additionalEntries = additionalEntries,
bottomContentPadding = bottomContentPadding
)
}
}
}
@Composable
private fun MultiselectForwardContent(
isSplitPane: Boolean,
args: MultiselectForwardFragmentArgs,
contactSearchViewModel: ContactSearchViewModel,
callback: MultiselectForwardFragment.Callback,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
contactSearchCallbacks: ContactSearchCallbacks,
additionalEntries: MappingEntryProvider<Any>,
bottomContentPadding: Dp = 0.dp,
modifier: Modifier = Modifier
) {
Row(modifier = modifier) {
if (isSplitPane) {
Box(
modifier = Modifier
.weight(1f)
.horizontalGutters()
.padding(top = 4.dp)
) {
Text(
text = stringResource(args.title),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
}
}
Column(
modifier = Modifier
.weight(1f)
.padding(bottom = bottomContentPadding)
) {
if (args.isSearchEnabled) {
val query by contactSearchViewModel.query.collectAsStateWithLifecycle()
RecipientSearchBar(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
.horizontalGutters(),
query = query ?: "",
onQueryChange = {
contactSearchViewModel.setQuery(it)
},
onSearch = {
contactSearchViewModel.setQuery(it)
},
onFocusChanged = {
if (it) {
callback.onSearchInputFocused()
}
}
)
}
ContactSearch(
viewModel = contactSearchViewModel,
mapStateToConfiguration = mapStateToConfiguration,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
additionalEntries = additionalEntries,
displayOptions = remember {
ContactSearchAdapter.DisplayOptions(
displayCheckBox = !args.selectSingleRecipient,
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER,
displayStoryRing = true
)
},
callbacks = contactSearchCallbacks
)
}
}
}
@@ -2,38 +2,44 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.v2.UntrustedRecords
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.livedata.Store
class MultiselectForwardViewModel(
private val storySendRequirements: Stories.MediaTransform.SendRequirements,
private val records: List<MultiShareArgs>,
private val isSelectionOnly: Boolean,
private val repository: MultiselectForwardRepository,
private val args: MultiselectForwardFragmentArgs,
private val identityChangesSince: Long = System.currentTimeMillis()
) : ViewModel() {
private val store = Store(
MultiselectForwardState(
storySendRequirements = storySendRequirements
storySendRequirements = args.storySendRequirements
)
)
val state: LiveData<MultiselectForwardState> = store.stateLiveData
val snapshot: MultiselectForwardState get() = store.state
private val internalBottomBarState = MutableStateFlow(
MultiselectForwardBottomBarState(
sendButtonColors = args.sendButtonColors,
isSendButtonVisible = !args.selectSingleRecipient
)
)
val bottomBarState: StateFlow<MultiselectForwardBottomBarState> = internalBottomBarState
private val disposables = CompositeDisposable()
init {
if (records.isNotEmpty()) {
disposables += repository.checkAllSelectedMediaCanBeSentToStories(records).subscribe { sendRequirements ->
if (args.multiShareArgs.isNotEmpty()) {
disposables += MultiselectForwardRepository.checkAllSelectedMediaCanBeSentToStories(args.multiShareArgs).subscribe { sendRequirements ->
store.update { it.copy(storySendRequirements = sendRequirements) }
}
}
@@ -43,7 +49,23 @@ class MultiselectForwardViewModel(
disposables.clear()
}
fun send(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
fun setSendEnabled(isSendEnabled: Boolean) {
internalBottomBarState.update { it.copy(isSendButtonEnabled = isSendEnabled) }
}
fun setShareSelection(selection: List<MultiselectForwardBottomBarState.SelectedContact>) {
internalBottomBarState.update { it.copy(selection = selection) }
}
fun setAddMessageVisible(isAddMessageVisible: Boolean) {
internalBottomBarState.update { it.copy(isAddMessageVisible = isAddMessageVisible) }
}
fun setMessage(message: String) {
internalBottomBarState.update { it.copy(message = message) }
}
fun send(selectedContacts: Set<ContactSearchKey>) {
if (SignalStore.tooltips.showMultiForwardDialog()) {
SignalStore.tooltips.markMultiForwardDialogSeen()
store.update { it.copy(stage = MultiselectForwardState.Stage.FirstConfirmation) }
@@ -51,7 +73,7 @@ class MultiselectForwardViewModel(
store.update { it.copy(stage = MultiselectForwardState.Stage.LoadingIdentities) }
UntrustedRecords.checkForBadIdentityRecords(selectedContacts.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java).toSet(), identityChangesSince) { identityRecords ->
if (identityRecords.isEmpty()) {
performSend(additionalMessage, selectedContacts)
performSend(selectedContacts)
} else {
store.update { state ->
state.copy(
@@ -66,26 +88,26 @@ class MultiselectForwardViewModel(
}
}
fun confirmFirstSend(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
send(additionalMessage, selectedContacts)
fun confirmFirstSend(selectedContacts: Set<ContactSearchKey>) {
send(selectedContacts)
}
fun confirmSafetySend(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
send(additionalMessage, selectedContacts)
fun confirmSafetySend(selectedContacts: Set<ContactSearchKey>) {
send(selectedContacts)
}
fun cancelSend() {
store.update { it.copy(stage = MultiselectForwardState.Stage.Selection) }
}
private fun performSend(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
private fun performSend(selectedContacts: Set<ContactSearchKey>) {
store.update { it.copy(stage = MultiselectForwardState.Stage.SendPending) }
if (records.isEmpty() || isSelectionOnly) {
if (args.multiShareArgs.isEmpty() || args.forceSelectionOnly) {
store.update { it.copy(stage = MultiselectForwardState.Stage.SelectionConfirmed(selectedContacts)) }
} else {
repository.send(
additionalMessage = additionalMessage,
multiShareArgs = records,
MultiselectForwardRepository.send(
additionalMessage = bottomBarState.value.message,
multiShareArgs = args.multiShareArgs,
shareContacts = selectedContacts,
MultiselectForwardRepository.MultiselectForwardResultHandlers(
onAllMessageSentSuccessfully = { store.update { it.copy(stage = MultiselectForwardState.Stage.Success) } },
@@ -95,15 +117,4 @@ class MultiselectForwardViewModel(
)
}
}
class Factory(
private val storySendRequirements: Stories.MediaTransform.SendRequirements,
private val records: List<MultiShareArgs>,
private val isSelectionOnly: Boolean,
private val repository: MultiselectForwardRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(MultiselectForwardViewModel(storySendRequirements, records, isSelectionOnly, repository)))
}
}
}
@@ -1,9 +1,12 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import androidx.compose.runtime.Composable
import androidx.fragment.app.FragmentManager
import kotlinx.collections.immutable.persistentHashMapOf
import org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
/**
* Allows a parent of MultiselectForwardFragment to provide a custom search page configuration.
@@ -21,4 +24,10 @@ interface SearchConfigurationProvider {
* @return An ArbitraryRepository or null. Returning null will result in not being able to use the Arbitrary section, keys, or data.
*/
fun getArbitraryRepository(): ArbitraryRepository? = null
/**
* @return a mapping of additional entries. Recommended to put your arbitrary stuff here.
*/
@Composable
fun getAdditionalEntries(): MappingEntryProvider<Any> = persistentHashMapOf()
}
@@ -431,7 +431,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
maybeScheduleRefreshProfileJob();
ConversationListFragmentExtensionsKt.listenToEventBusWhileResumed(this, mainNavigationViewModel.getDetailLocation());
String query = contactSearchViewModel.getQuery();
String query = contactSearchViewModel.getQuery().getValue();
if (query != null) {
onSearchQueryUpdated(query);
}
@@ -3,58 +3,39 @@ package org.thoughtcrime.securesms.mediasend.v2.stories
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.view.ViewGroup
import android.widget.ImageView
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.runtime.Composable
import androidx.fragment.app.FragmentManager
import com.bumptech.glide.Glide
import kotlinx.parcelize.Parcelize
import org.signal.core.util.getParcelableArrayListExtraCompat
import org.signal.glide.decryptableuri.DecryptableUri
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardActivity
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.rememberMappingEntryProvider
class StoriesMultiselectForwardActivity : MultiselectForwardActivity() {
companion object {
private const val PREVIEW_MEDIA = "preview_media"
}
override val contentViewId: Int = R.layout.stories_multiselect_forward_activity
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
val preview1View: ImageView = findViewById(R.id.preview_media_1)
val preview2View: ImageView = findViewById(R.id.preview_media_2)
val previewMedia: List<Uri> = intent.getParcelableArrayListExtraCompat(PREVIEW_MEDIA, Uri::class.java)!!
preview1View.visible = previewMedia.isNotEmpty()
preview2View.visible = previewMedia.size > 1
if (previewMedia.isNotEmpty()) {
Glide.with(this)
.load(DecryptableUri(previewMedia.first()))
.into(preview1View)
}
if (previewMedia.size > 1) {
Glide.with(this).load(DecryptableUri(previewMedia[1])).into(preview2View)
}
private const val PREVIEW_ITEM = "preview_item"
}
override fun getSearchConfiguration(fragmentManager: FragmentManager, contactSearchState: ContactSearchState): ContactSearchConfiguration? {
return ContactSearchConfiguration.build {
query = contactSearchState.query
addSection(
ContactSearchConfiguration.Section.Arbitrary(setOf(PREVIEW_ITEM))
)
addSection(
ContactSearchConfiguration.Section.Stories(
groupStories = contactSearchState.groupStories,
@@ -65,9 +46,40 @@ class StoriesMultiselectForwardActivity : MultiselectForwardActivity() {
}
}
@Suppress("WrongViewCast")
override fun getContainer(): ViewGroup {
return findViewById(R.id.content)
private val previewMedia: List<Uri> by lazy { intent.getParcelableArrayListExtraCompat(PREVIEW_MEDIA, Uri::class.java).orEmpty() }
override fun getArbitraryRepository(): ArbitraryRepository {
return object : ArbitraryRepository {
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int = if (previewMedia.isEmpty()) 0 else 1
override fun getData(
section: ContactSearchConfiguration.Section.Arbitrary,
query: String?,
startIndex: Int,
endIndex: Int,
totalSearchSize: Int
): List<ContactSearchData.Arbitrary> {
return if (previewMedia.isEmpty()) emptyList() else listOf(ContactSearchData.Arbitrary(PREVIEW_ITEM))
}
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
return PreviewEntryMappingModel()
}
}
}
@Composable
override fun getAdditionalEntries(): MappingEntryProvider<Any> {
return rememberMappingEntryProvider {
entry<PreviewEntryMappingModel> {
StoryMediaPreviews(previews = previewMedia)
}
}
}
private class PreviewEntryMappingModel : MappingModel<PreviewEntryMappingModel> {
override fun areItemsTheSame(newItem: PreviewEntryMappingModel): Boolean = true
override fun areContentsTheSame(newItem: PreviewEntryMappingModel): Boolean = true
}
class SelectionContract : ActivityResultContract<Args, List<ContactSearchKey.RecipientSearchKey>>() {
@@ -0,0 +1,95 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v2.stories
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.glide.compose.GlideImage
import org.signal.glide.compose.GlideImageScaleType
import org.signal.glide.decryptableuri.DecryptableUri
/**
* The stacked, overlapping story media thumbnails shown as the first item in the contact list in [StoriesMultiselectForwardActivity].
*
* The first preview sits in front with a background-colored stroke; a second, if present, is tucked behind it,
* rotated and offset to the side.
*/
@Composable
fun StoryMediaPreviews(
previews: List<Uri>,
modifier: Modifier = Modifier
) {
if (previews.isEmpty()) {
return
}
Box(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 22.5.dp),
contentAlignment = Alignment.Center
) {
if (previews.size > 1) {
Box(
modifier = Modifier
.size(width = 110.dp, height = 177.dp)
.offset(x = (-28).dp)
.rotate(-15f)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
GlideImage(
model = remember { DecryptableUri(previews[1]) },
scaleType = GlideImageScaleType.CENTER_CROP,
modifier = Modifier.fillMaxSize()
)
}
}
Box(
modifier = Modifier
.size(width = 120.dp, height = 215.dp)
.border(width = 3.dp, color = MaterialTheme.colorScheme.background, shape = RoundedCornerShape(12.dp))
.padding(1.5.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
GlideImage(
model = remember { DecryptableUri(previews[0]) },
scaleType = GlideImageScaleType.CENTER_CROP,
modifier = Modifier.fillMaxSize()
)
}
}
}
@DayNightPreviews
@Composable
private fun StoryMediaPreviewsPreview() {
Previews.Preview {
StoryMediaPreviews(
previews = listOf(Uri.EMPTY, Uri.EMPTY)
)
}
}
@@ -15,7 +15,6 @@ import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.text.buildSpannedString
import com.google.android.material.appbar.MaterialToolbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.models.media.Media
@@ -35,7 +34,6 @@ import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFullScreenDialogFragment
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity.Companion.share
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareDialogs
@@ -44,9 +42,7 @@ import org.thoughtcrime.securesms.sharing.MultiShareSender.MultiShareSendResultC
import org.thoughtcrime.securesms.sharing.interstitial.ShareInterstitialActivity
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.visible
import java.util.concurrent.TimeUnit
import org.signal.core.ui.R as CoreUiR
class ShareActivity : PassphraseRequiredActivity(), MultiselectForwardFragment.Callback {
@@ -86,7 +82,7 @@ class ShareActivity : PassphraseRequiredActivity(), MultiselectForwardFragment.C
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
setContentView(R.layout.share_activity_v2)
setContentView(R.layout.multiselect_forward_activity)
val isIntentValid = getUnresolvedShareData().either(
onSuccess = {
@@ -110,16 +106,6 @@ class ShareActivity : PassphraseRequiredActivity(), MultiselectForwardFragment.C
}
}
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
if (intent?.getBooleanExtra(EXTRA_NAVIGATION, false) == true) {
toolbar.setTitle(getTitleFromExtras())
toolbar.setNavigationIcon(CoreUiR.drawable.symbol_arrow_start_24)
toolbar.setNavigationOnClickListener { finish() }
} else {
toolbar.visible = false
}
lifecycleDisposable.bindTo(this)
lifecycleDisposable += viewModel.events.subscribe { shareEvent ->
when (shareEvent) {
@@ -193,7 +179,7 @@ class ShareActivity : PassphraseRequiredActivity(), MultiselectForwardFragment.C
viewModel.onContactSelectionConfirmed(contactSearchKeys)
}
override fun getContainer(): ViewGroup = findViewById(R.id.container)
override fun getContainer(): ViewGroup = findViewById(R.id.fragment_container_wrapper)
override fun getDialogBackgroundColor(): Int = ContextCompat.getColor(this, R.color.signal_background_primary)
@@ -255,7 +241,9 @@ class ShareActivity : PassphraseRequiredActivity(), MultiselectForwardFragment.C
}
private fun ensureFragment(resolvedShareData: ResolvedShareData) {
if (!supportFragmentManager.isStateSaved && supportFragmentManager.fragments.none { it is MultiselectForwardFullScreenDialogFragment }) {
val hasToolbar = intent?.getBooleanExtra(EXTRA_NAVIGATION, false) == true
if (!supportFragmentManager.isStateSaved && supportFragmentManager.fragments.none { it is MultiselectForwardFragment }) {
supportFragmentManager.beginTransaction()
.replace(
R.id.fragment_container,
@@ -264,7 +252,8 @@ class ShareActivity : PassphraseRequiredActivity(), MultiselectForwardFragment.C
multiShareArgs = listOf(resolvedShareData.toMultiShareArgs()),
title = getTitleFromExtras(),
forceDisableAddMessage = true,
forceSelectionOnly = true
forceSelectionOnly = true,
forceHideToolbar = !hasToolbar
)
)
).commitNow()