mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-08 06:17:37 +01:00
Migrate MultiselectForward to Jetpack Compose.
This commit is contained in:
committed by
Cody Henthorne
parent
9478cdf049
commit
5909a1b92a
+7
-8
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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),
|
||||
|
||||
-9
@@ -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)
|
||||
}
|
||||
|
||||
+303
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
}
|
||||
+32
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
-110
@@ -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)
|
||||
}
|
||||
|
||||
+5
@@ -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 {
|
||||
|
||||
+13
-6
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+178
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-32
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+45
-33
@@ -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>>() {
|
||||
|
||||
+95
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user