mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-05 01:10:48 +00:00
Add split-pane UI for create group screen.
This commit is contained in:
committed by
Greyson Parrelli
parent
d763baa270
commit
d6446d2954
@@ -1059,6 +1059,11 @@
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".groups.ui.creategroup.CreateGroupActivityV2"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity android:name=".groups.ui.addtogroup.AddToGroupsActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:exported="false"/>
|
||||
|
||||
@@ -147,6 +147,11 @@ public final class ContactFilterView extends FrameLayout {
|
||||
ViewUtil.focusAndShowKeyboard(searchText);
|
||||
}
|
||||
|
||||
public void setText(String text) {
|
||||
searchText.setText(text);
|
||||
searchText.setSelection(text.length());
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
searchText.setText("");
|
||||
notifyListener();
|
||||
|
||||
@@ -86,21 +86,21 @@ data class ContactSelectionArguments(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object Defaults {
|
||||
const val DISPLAY_MODE = ContactSelectionDisplayMode.FLAG_ALL
|
||||
const val IS_REFRESHABLE = true
|
||||
const val ENABLE_CREATE_NEW_GROUP = false
|
||||
const val ENABLE_FIND_BY_USERNAME = false
|
||||
const val ENABLE_FIND_BY_PHONE_NUMBER = false
|
||||
const val INCLUDE_RECENTS = false
|
||||
const val INCLUDE_CHAT_TYPES = false
|
||||
val SELECTION_LIMITS: SelectionLimits? = null
|
||||
val CURRENT_SELECTION: Set<RecipientId> = emptySet()
|
||||
const val DISPLAY_CHIPS = true
|
||||
const val RECYCLER_PADDING_BOTTOM = -1
|
||||
const val RECYCLER_CHILD_CLIPPING = true
|
||||
object Defaults {
|
||||
const val DISPLAY_MODE = ContactSelectionDisplayMode.FLAG_ALL
|
||||
const val IS_REFRESHABLE = true
|
||||
const val ENABLE_CREATE_NEW_GROUP = false
|
||||
const val ENABLE_FIND_BY_USERNAME = false
|
||||
const val ENABLE_FIND_BY_PHONE_NUMBER = false
|
||||
const val INCLUDE_RECENTS = false
|
||||
const val INCLUDE_CHAT_TYPES = false
|
||||
val SELECTION_LIMITS: SelectionLimits? = null
|
||||
val CURRENT_SELECTION: Set<RecipientId> = emptySet()
|
||||
const val DISPLAY_CHIPS = true
|
||||
const val RECYCLER_PADDING_BOTTOM = -1
|
||||
const val RECYCLER_CHILD_CLIPPING = true
|
||||
|
||||
fun canSelectSelf(selectionLimits: SelectionLimits?): Boolean = selectionLimits == null
|
||||
fun canSelectSelf(selectionLimits: SelectionLimits?): Boolean = selectionLimits == null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ private fun NewConversationScreen(
|
||||
contract = FindByActivity.Contract(),
|
||||
onResult = { recipientId ->
|
||||
if (recipientId != null) {
|
||||
viewModel.onMessage(recipientId)
|
||||
viewModel.openConversation(recipientId)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -125,14 +125,16 @@ private fun NewConversationScreen(
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val callbacks = remember {
|
||||
object : UiCallbacks {
|
||||
override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query)
|
||||
override fun onCreateNewGroup() = createGroupLauncher.launch(CreateGroupActivity.newIntent(context))
|
||||
override fun onFindByUsername() = findByLauncher.launch(FindByMode.USERNAME)
|
||||
override fun onFindByPhoneNumber() = findByLauncher.launch(FindByMode.PHONE_NUMBER)
|
||||
override fun shouldAllowSelection(id: RecipientId): Boolean = true
|
||||
override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = viewModel.onRecipientSelected(id, phone)
|
||||
override fun onMessage(id: RecipientId) = viewModel.onMessage(id)
|
||||
override fun onVoiceCall(recipient: Recipient) = CommunicationActions.startVoiceCall(context, recipient, viewModel::onUserAlreadyInACall)
|
||||
override fun onVideoCall(recipient: Recipient) = CommunicationActions.startVideoCall(context, recipient, viewModel::onUserAlreadyInACall)
|
||||
override suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean = true
|
||||
override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = viewModel.openConversation(id, phone)
|
||||
override fun onPendingRecipientSelectionsConsumed() = Unit
|
||||
override fun onMessage(id: RecipientId) = viewModel.openConversation(id)
|
||||
override fun onVoiceCall(recipient: Recipient) = CommunicationActions.startVoiceCall(context, recipient, viewModel::showUserAlreadyInACall)
|
||||
override fun onVideoCall(recipient: Recipient) = CommunicationActions.startVideoCall(context, recipient, viewModel::showUserAlreadyInACall)
|
||||
|
||||
override fun onRemove(recipient: Recipient) = viewModel.showRemoveConfirmation(recipient)
|
||||
override fun onRemoveConfirmed(recipient: Recipient) {
|
||||
@@ -146,8 +148,8 @@ private fun NewConversationScreen(
|
||||
|
||||
override fun onInviteToSignal() = context.startActivity(AppSettingsActivity.invite(context))
|
||||
override fun onRefresh() = viewModel.refresh()
|
||||
override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.onUserMessageDismissed()
|
||||
override fun onContactsListReset() = viewModel.onContactsListReset()
|
||||
override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.clearUserMessage()
|
||||
override fun onContactsListReset() = viewModel.clearShouldResetContactsList()
|
||||
override fun onBackPressed() = closeScreen()
|
||||
}
|
||||
}
|
||||
@@ -256,7 +258,7 @@ private fun NewConversationScreenUi(
|
||||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
|
||||
if (uiState.isRefreshingRecipient) {
|
||||
if (uiState.isLookingUpRecipient) {
|
||||
Dialogs.IndeterminateProgressDialog()
|
||||
}
|
||||
}
|
||||
@@ -320,11 +322,13 @@ private interface UiCallbacks :
|
||||
fun onBackPressed()
|
||||
|
||||
object Empty : UiCallbacks {
|
||||
override fun onSearchQueryChanged(query: String) = Unit
|
||||
override fun onCreateNewGroup() = Unit
|
||||
override fun onFindByUsername() = Unit
|
||||
override fun onFindByPhoneNumber() = Unit
|
||||
override fun shouldAllowSelection(id: RecipientId): Boolean = true
|
||||
override suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean = true
|
||||
override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit
|
||||
override fun onPendingRecipientSelectionsConsumed() = Unit
|
||||
override fun onMessage(id: RecipientId) = Unit
|
||||
override fun onVoiceCall(recipient: Recipient) = Unit
|
||||
override fun onVideoCall(recipient: Recipient) = Unit
|
||||
@@ -347,6 +351,7 @@ private fun NewConversationRecipientPicker(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
RecipientPicker(
|
||||
searchQuery = uiState.searchQuery,
|
||||
isRefreshing = uiState.isRefreshingContacts,
|
||||
shouldResetContactsList = uiState.shouldResetContactsList,
|
||||
callbacks = RecipientPickerCallbacks(
|
||||
|
||||
@@ -37,41 +37,39 @@ class NewConversationViewModel : ViewModel() {
|
||||
|
||||
private val contactsManagementRepo = ContactsManagementRepository(AppDependencies.application)
|
||||
|
||||
fun onMessage(id: RecipientId): Unit = openConversation(recipientId = id)
|
||||
fun onSearchQueryChanged(query: String) {
|
||||
internalUiState.update { it.copy(searchQuery = query) }
|
||||
}
|
||||
|
||||
fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) {
|
||||
fun openConversation(recipientId: RecipientId) {
|
||||
internalUiState.update { it.copy(pendingDestination = recipientId) }
|
||||
}
|
||||
|
||||
fun openConversation(id: RecipientId?, phone: PhoneNumber?) {
|
||||
when {
|
||||
id != null -> openConversation(recipientId = id)
|
||||
|
||||
SignalStore.account.isRegistered -> {
|
||||
Log.d(TAG, "[onRecipientSelected] Missing recipientId: attempting to look up.")
|
||||
resolveAndOpenConversation(phone)
|
||||
Log.d(TAG, "[openConversation] Missing recipientId: attempting to look up.")
|
||||
resolveAndOpenConversation(phone!!)
|
||||
}
|
||||
|
||||
else -> Log.w(TAG, "[onRecipientSelected] Cannot look up recipient: account not registered.")
|
||||
else -> Log.w(TAG, "[openConversation] Cannot look up recipient: account not registered.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun openConversation(recipientId: RecipientId) {
|
||||
internalUiState.update { it.copy(pendingDestination = recipientId) }
|
||||
}
|
||||
|
||||
private fun resolveAndOpenConversation(phone: PhoneNumber?) {
|
||||
private fun resolveAndOpenConversation(phone: PhoneNumber) {
|
||||
viewModelScope.launch {
|
||||
internalUiState.update { it.copy(isRefreshingRecipient = true) }
|
||||
internalUiState.update { it.copy(isLookingUpRecipient = true) }
|
||||
|
||||
val lookupResult = withContext(Dispatchers.IO) {
|
||||
if (phone != null) {
|
||||
RecipientRepository.lookupNewE164(inputE164 = phone.value)
|
||||
} else {
|
||||
RecipientRepository.LookupResult.InvalidEntry
|
||||
}
|
||||
RecipientRepository.lookupNewE164(inputE164 = phone.value)
|
||||
}
|
||||
|
||||
when (lookupResult) {
|
||||
is RecipientRepository.LookupResult.Success -> {
|
||||
val recipient = Recipient.resolved(lookupResult.recipientId)
|
||||
internalUiState.update { it.copy(isRefreshingRecipient = false) }
|
||||
internalUiState.update { it.copy(isLookingUpRecipient = false) }
|
||||
|
||||
if (recipient.isRegistered && recipient.hasServiceId) {
|
||||
openConversation(recipient.id)
|
||||
@@ -83,7 +81,7 @@ class NewConversationViewModel : ViewModel() {
|
||||
is RecipientRepository.LookupResult.NotFound, is RecipientRepository.LookupResult.InvalidEntry -> {
|
||||
internalUiState.update {
|
||||
it.copy(
|
||||
isRefreshingRecipient = false,
|
||||
isLookingUpRecipient = false,
|
||||
userMessage = Info.RecipientNotSignalUser(phone)
|
||||
)
|
||||
}
|
||||
@@ -92,7 +90,7 @@ class NewConversationViewModel : ViewModel() {
|
||||
is RecipientRepository.LookupResult.NetworkError -> {
|
||||
internalUiState.update {
|
||||
it.copy(
|
||||
isRefreshingRecipient = false,
|
||||
isLookingUpRecipient = false,
|
||||
userMessage = Info.NetworkError
|
||||
)
|
||||
}
|
||||
@@ -137,11 +135,11 @@ class NewConversationViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun onUserAlreadyInACall() {
|
||||
fun showUserAlreadyInACall() {
|
||||
internalUiState.update { it.copy(userMessage = Info.UserAlreadyInAnotherCall) }
|
||||
}
|
||||
|
||||
fun onContactsListReset() {
|
||||
fun clearShouldResetContactsList() {
|
||||
internalUiState.update { it.copy(shouldResetContactsList = false) }
|
||||
}
|
||||
|
||||
@@ -157,14 +155,15 @@ class NewConversationViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun onUserMessageDismissed() {
|
||||
fun clearUserMessage() {
|
||||
internalUiState.update { it.copy(userMessage = null) }
|
||||
}
|
||||
}
|
||||
|
||||
data class NewConversationUiState(
|
||||
val forceSplitPaneOnCompactLandscape: Boolean = SignalStore.internal.forceSplitPaneOnCompactLandscape,
|
||||
val isRefreshingRecipient: Boolean = false,
|
||||
val searchQuery: String = "",
|
||||
val isLookingUpRecipient: Boolean = false,
|
||||
val isRefreshingContacts: Boolean = false,
|
||||
val shouldResetContactsList: Boolean = false,
|
||||
val pendingDestination: RecipientId? = null,
|
||||
@@ -174,7 +173,7 @@ data class NewConversationUiState(
|
||||
sealed interface Info : UserMessage {
|
||||
data class RecipientRemoved(val recipient: Recipient) : Info
|
||||
data class RecipientBlocked(val recipient: Recipient) : Info
|
||||
data class RecipientNotSignalUser(val phone: PhoneNumber?) : Info
|
||||
data class RecipientNotSignalUser(val phone: PhoneNumber) : Info
|
||||
data object UserAlreadyInAnotherCall : Info
|
||||
data object NetworkError : Info
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.fragment.compose.rememberFragmentState
|
||||
@@ -35,15 +37,20 @@ import kotlinx.coroutines.withContext
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Fragments
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments
|
||||
import org.thoughtcrime.securesms.conversation.RecipientPicker.DisplayMode.Companion.flag
|
||||
import org.thoughtcrime.securesms.conversation.RecipientPickerCallbacks.ContextMenu
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.recipients.PhoneNumber
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -56,21 +63,24 @@ import java.util.function.Consumer
|
||||
*/
|
||||
@Composable
|
||||
fun RecipientPicker(
|
||||
searchQuery: String,
|
||||
displayModes: Set<RecipientPicker.DisplayMode> = setOf(RecipientPicker.DisplayMode.ALL),
|
||||
selectionLimits: SelectionLimits? = ContactSelectionArguments.Defaults.SELECTION_LIMITS,
|
||||
isRefreshing: Boolean,
|
||||
focusAndShowKeyboard: Boolean = LocalConfiguration.current.screenHeightDp.dp > 600.dp,
|
||||
shouldResetContactsList: Boolean,
|
||||
pendingRecipientSelections: Set<RecipientId> = emptySet(),
|
||||
shouldResetContactsList: Boolean = false,
|
||||
listBottomPadding: Dp? = null,
|
||||
clipListToPadding: Boolean = ContactSelectionArguments.Defaults.RECYCLER_CHILD_CLIPPING,
|
||||
callbacks: RecipientPickerCallbacks,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
RecipientSearchField(
|
||||
onFilterChanged = { filter ->
|
||||
searchQuery = filter
|
||||
},
|
||||
searchQuery = searchQuery,
|
||||
onFilterChanged = { filter -> callbacks.listActions.onSearchQueryChanged(query = filter) },
|
||||
focusAndShowKeyboard = focusAndShowKeyboard,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -78,9 +88,14 @@ fun RecipientPicker(
|
||||
)
|
||||
|
||||
RecipientSearchResultsList(
|
||||
displayModes = displayModes,
|
||||
selectionLimits = selectionLimits,
|
||||
searchQuery = searchQuery,
|
||||
isRefreshing = isRefreshing,
|
||||
pendingRecipientSelections = pendingRecipientSelections,
|
||||
shouldResetContactsList = shouldResetContactsList,
|
||||
bottomPadding = listBottomPadding,
|
||||
clipListToPadding = clipListToPadding,
|
||||
callbacks = callbacks,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -96,6 +111,7 @@ fun RecipientPicker(
|
||||
*/
|
||||
@Composable
|
||||
private fun RecipientSearchField(
|
||||
searchQuery: String,
|
||||
onFilterChanged: (String) -> Unit,
|
||||
@StringRes hintText: Int? = null,
|
||||
focusAndShowKeyboard: Boolean = false,
|
||||
@@ -108,6 +124,10 @@ private fun RecipientSearchField(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(searchQuery) {
|
||||
wrappedView.setText(searchQuery)
|
||||
}
|
||||
|
||||
// TODO [jeff] This causes the keyboard to re-open on rotation, which doesn't match the existing behavior of ContactFilterView. To fix this,
|
||||
// RecipientSearchField needs to be converted to compose so we can use FocusRequestor.
|
||||
LaunchedEffect(focusAndShowKeyboard) {
|
||||
@@ -134,17 +154,26 @@ private fun RecipientSearchField(
|
||||
|
||||
@Composable
|
||||
private fun RecipientSearchResultsList(
|
||||
displayModes: Set<RecipientPicker.DisplayMode>,
|
||||
searchQuery: String,
|
||||
isRefreshing: Boolean,
|
||||
pendingRecipientSelections: Set<RecipientId>,
|
||||
shouldResetContactsList: Boolean,
|
||||
selectionLimits: SelectionLimits? = ContactSelectionArguments.Defaults.SELECTION_LIMITS,
|
||||
bottomPadding: Dp? = null,
|
||||
clipListToPadding: Boolean = ContactSelectionArguments.Defaults.RECYCLER_CHILD_CLIPPING,
|
||||
callbacks: RecipientPickerCallbacks,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val fragmentArgs = ContactSelectionArguments(
|
||||
displayMode = displayModes.flag,
|
||||
isRefreshable = callbacks.refresh != null,
|
||||
enableCreateNewGroup = callbacks.newConversation != null,
|
||||
enableFindByUsername = callbacks.findByUsername != null,
|
||||
enableFindByPhoneNumber = callbacks.findByPhoneNumber != null
|
||||
enableFindByPhoneNumber = callbacks.findByPhoneNumber != null,
|
||||
selectionLimits = selectionLimits,
|
||||
recyclerPadBottom = with(LocalDensity.current) { bottomPadding?.toPx()?.toInt() ?: ContactSelectionArguments.Defaults.RECYCLER_PADDING_BOTTOM },
|
||||
recyclerChildClipping = clipListToPadding
|
||||
).toArgumentBundle()
|
||||
|
||||
val fragmentState = rememberFragmentState()
|
||||
@@ -186,6 +215,22 @@ private fun RecipientSearchResultsList(
|
||||
wasRefreshing = isRefreshing
|
||||
}
|
||||
|
||||
LaunchedEffect(pendingRecipientSelections) {
|
||||
if (pendingRecipientSelections.isNotEmpty()) {
|
||||
currentFragment?.let { fragment ->
|
||||
pendingRecipientSelections.forEach { recipientId ->
|
||||
currentFragment?.addRecipientToSelectionIfAble(recipientId)
|
||||
}
|
||||
callbacks.listActions.onPendingRecipientSelectionsConsumed()
|
||||
|
||||
callbacks.listActions.onSelectionChanged(
|
||||
newSelections = fragment.selectedContacts,
|
||||
totalMembersCount = fragment.totalMemberCount
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldResetContactsList) {
|
||||
if (shouldResetContactsList) {
|
||||
currentFragment?.reset()
|
||||
@@ -226,19 +271,26 @@ private fun ContactSelectionListFragment.setUpCallbacks(
|
||||
chatType: Optional<ChatType?>,
|
||||
resultConsumer: Consumer<Boolean?>
|
||||
) {
|
||||
val recipientId = recipientId.get()
|
||||
val shouldAllowSelection = callbacks.listActions.shouldAllowSelection(recipientId)
|
||||
if (shouldAllowSelection) {
|
||||
callbacks.listActions.onRecipientSelected(
|
||||
id = recipientId,
|
||||
phone = number?.let(::PhoneNumber)
|
||||
)
|
||||
val recipientId = recipientId.orNull()
|
||||
val phone = number?.let(::PhoneNumber)
|
||||
|
||||
coroutineScope.launch {
|
||||
val shouldAllowSelection = callbacks.listActions.shouldAllowSelection(recipientId, phone)
|
||||
if (shouldAllowSelection) {
|
||||
callbacks.listActions.onRecipientSelected(recipientId, phone)
|
||||
}
|
||||
resultConsumer.accept(shouldAllowSelection)
|
||||
}
|
||||
resultConsumer.accept(shouldAllowSelection)
|
||||
}
|
||||
|
||||
override fun onContactDeselected(recipientId: Optional<RecipientId?>, number: String?, chatType: Optional<ChatType?>) = Unit
|
||||
override fun onSelectionChanged() = Unit
|
||||
|
||||
override fun onSelectionChanged() {
|
||||
callbacks.listActions.onSelectionChanged(
|
||||
newSelections = fragment.selectedContacts,
|
||||
totalMembersCount = fragment.totalMemberCount
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
fragment.setOnItemLongClickListener { anchorView, contactSearchKey, recyclerView ->
|
||||
@@ -331,6 +383,7 @@ private suspend fun showItemContextMenu(
|
||||
@Composable
|
||||
private fun RecipientPickerPreview() {
|
||||
RecipientPicker(
|
||||
searchQuery = "",
|
||||
isRefreshing = false,
|
||||
shouldResetContactsList = false,
|
||||
callbacks = RecipientPickerCallbacks(
|
||||
@@ -353,13 +406,18 @@ data class RecipientPickerCallbacks(
|
||||
*
|
||||
* This is called before [onRecipientSelected] to provide a chance to prevent the selection.
|
||||
*/
|
||||
fun shouldAllowSelection(id: RecipientId): Boolean
|
||||
fun onSearchQueryChanged(query: String)
|
||||
suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean
|
||||
fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?)
|
||||
fun onContactsListReset()
|
||||
fun onSelectionChanged(newSelections: List<SelectedContact>, totalMembersCount: Int) = Unit
|
||||
fun onPendingRecipientSelectionsConsumed()
|
||||
fun onContactsListReset() = Unit
|
||||
|
||||
object Empty : ListActions {
|
||||
override fun shouldAllowSelection(id: RecipientId): Boolean = false
|
||||
override fun onSearchQueryChanged(query: String) = Unit
|
||||
override suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean = true
|
||||
override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit
|
||||
override fun onPendingRecipientSelectionsConsumed() = Unit
|
||||
override fun onContactsListReset() = Unit
|
||||
}
|
||||
}
|
||||
@@ -389,3 +447,28 @@ data class RecipientPickerCallbacks(
|
||||
fun onFindByPhoneNumber()
|
||||
}
|
||||
}
|
||||
|
||||
object RecipientPicker {
|
||||
/**
|
||||
* Enum wrapper for [ContactSelectionDisplayMode].
|
||||
*/
|
||||
enum class DisplayMode(val flag: Int) {
|
||||
PUSH(flag = ContactSelectionDisplayMode.FLAG_PUSH),
|
||||
SMS(flag = ContactSelectionDisplayMode.FLAG_SMS),
|
||||
ACTIVE_GROUPS(flag = ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS),
|
||||
INACTIVE_GROUPS(flag = ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS),
|
||||
SELF(flag = ContactSelectionDisplayMode.FLAG_SELF),
|
||||
BLOCK(flag = ContactSelectionDisplayMode.FLAG_BLOCK),
|
||||
HIDE_GROUPS_V1(flag = ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1),
|
||||
HIDE_NEW(flag = ContactSelectionDisplayMode.FLAG_HIDE_NEW),
|
||||
HIDE_RECENT_HEADER(flag = ContactSelectionDisplayMode.FLAG_HIDE_RECENT_HEADER),
|
||||
GROUPS_AFTER_CONTACTS(flag = ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS),
|
||||
GROUP_MEMBERS(flag = ContactSelectionDisplayMode.FLAG_GROUP_MEMBERS),
|
||||
ALL(flag = ContactSelectionDisplayMode.FLAG_ALL);
|
||||
|
||||
companion object {
|
||||
val Set<DisplayMode>.flag: Int
|
||||
get() = fold(initial = 0) { acc, displayMode -> acc or displayMode.flag }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.groups.ui.creategroup
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.compose.ScreenTitlePane
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact
|
||||
import org.thoughtcrime.securesms.conversation.RecipientPicker
|
||||
import org.thoughtcrime.securesms.conversation.RecipientPickerCallbacks
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.UserMessage
|
||||
import org.thoughtcrime.securesms.recipients.PhoneNumber
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity
|
||||
import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode
|
||||
import org.thoughtcrime.securesms.window.AppScaffold
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator
|
||||
import java.text.NumberFormat
|
||||
|
||||
/**
|
||||
* Allows creation of a Signal group by selecting from a list of recipients.
|
||||
*/
|
||||
class CreateGroupActivityV2 : PassphraseRequiredActivity() {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun createIntent(context: Context): Intent {
|
||||
return Intent(context, CreateGroupActivityV2::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
|
||||
val navigateBack = onBackPressedDispatcher::onBackPressed
|
||||
|
||||
setContent {
|
||||
SignalTheme {
|
||||
CreateGroupScreen(
|
||||
closeScreen = navigateBack
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateGroupScreen(
|
||||
viewModel: CreateGroupViewModel = viewModel { CreateGroupViewModel() },
|
||||
closeScreen: () -> Unit
|
||||
) {
|
||||
val findByLauncher: ActivityResultLauncher<FindByMode> = rememberLauncherForActivityResult(
|
||||
contract = FindByActivity.Contract(),
|
||||
onResult = { id -> id?.let(viewModel::selectRecipient) }
|
||||
)
|
||||
|
||||
val callbacks = remember {
|
||||
object : UiCallbacks {
|
||||
override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query)
|
||||
override fun onFindByUsername() = findByLauncher.launch(FindByMode.USERNAME)
|
||||
override fun onFindByPhoneNumber() = findByLauncher.launch(FindByMode.PHONE_NUMBER)
|
||||
override suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean = viewModel.shouldAllowSelection(id, phone)
|
||||
override fun onSelectionChanged(newSelections: List<SelectedContact>, totalMembersCount: Int) = viewModel.onSelectionChanged(newSelections, totalMembersCount)
|
||||
override fun onPendingRecipientSelectionsConsumed() = viewModel.clearPendingRecipientSelections()
|
||||
override fun onNextClicked(): Unit = viewModel.continueToGroupDetails()
|
||||
override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.clearUserMessage()
|
||||
override fun onBackPressed() = closeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
CreateGroupScreenUi(
|
||||
uiState = uiState,
|
||||
callbacks = callbacks
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
private fun CreateGroupScreenUi(
|
||||
uiState: CreateGroupUiState,
|
||||
callbacks: UiCallbacks
|
||||
) {
|
||||
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
|
||||
val isSplitPane = windowSizeClass.isSplitPane(forceSplitPaneOnCompactLandscape = uiState.forceSplitPaneOnCompactLandscape)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val titleText = if (uiState.newSelections.isNotEmpty()) {
|
||||
pluralStringResource(
|
||||
id = R.plurals.CreateGroupActivity__s_members,
|
||||
count = uiState.totalMembersCount,
|
||||
NumberFormat.getInstance().format(uiState.totalMembersCount)
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.CreateGroupActivity__select_members)
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
topBarContent = {
|
||||
Scaffolds.DefaultTopAppBar(
|
||||
title = if (!isSplitPane) titleText else "",
|
||||
titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) },
|
||||
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
|
||||
navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description),
|
||||
onNavigationClick = callbacks::onBackPressed
|
||||
)
|
||||
},
|
||||
|
||||
secondaryContent = {
|
||||
if (isSplitPane) {
|
||||
ScreenTitlePane(
|
||||
title = titleText,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
CreateGroupRecipientPicker(
|
||||
uiState = uiState,
|
||||
callbacks = callbacks
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
primaryContent = {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
CreateGroupRecipientPicker(
|
||||
uiState = uiState,
|
||||
callbacks = callbacks,
|
||||
modifier = Modifier.widthIn(max = windowSizeClass.detailPaneMaxContentWidth)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
snackbarHost = {
|
||||
SnackbarHost(snackbarHostState)
|
||||
},
|
||||
|
||||
navigator = rememberAppScaffoldNavigator(
|
||||
isSplitPane = isSplitPane
|
||||
)
|
||||
)
|
||||
|
||||
UserMessagesHost(
|
||||
userMessage = uiState.userMessage,
|
||||
onDismiss = callbacks::onUserMessageDismissed
|
||||
)
|
||||
|
||||
if (uiState.isLookingUpRecipient) {
|
||||
Dialogs.IndeterminateProgressDialog()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateGroupRecipientPicker(
|
||||
uiState: CreateGroupUiState,
|
||||
callbacks: UiCallbacks,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
RecipientPicker(
|
||||
searchQuery = uiState.searchQuery,
|
||||
displayModes = setOf(RecipientPicker.DisplayMode.PUSH),
|
||||
selectionLimits = uiState.selectionLimits,
|
||||
pendingRecipientSelections = uiState.pendingRecipientSelections,
|
||||
isRefreshing = false,
|
||||
listBottomPadding = 64.dp,
|
||||
clipListToPadding = false,
|
||||
callbacks = RecipientPickerCallbacks(
|
||||
listActions = callbacks,
|
||||
findByUsername = callbacks,
|
||||
findByPhoneNumber = callbacks
|
||||
),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(vertical = 12.dp)
|
||||
)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = uiState.newSelections.isNotEmpty(),
|
||||
transitionSpec = {
|
||||
ContentTransform(
|
||||
targetContentEnter = EnterTransition.None,
|
||||
initialContentExit = ExitTransition.None
|
||||
) using SizeTransform(sizeAnimationSpec = { _, _ -> tween(300) })
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
|
||||
) { hasSelectedContacts ->
|
||||
if (hasSelectedContacts) {
|
||||
FilledTonalIconButton(
|
||||
onClick = callbacks::onNextClicked,
|
||||
content = {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_end_24),
|
||||
contentDescription = stringResource(R.string.CreateGroupActivity__accessibility_next)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Buttons.MediumTonal(
|
||||
onClick = callbacks::onNextClicked
|
||||
) {
|
||||
Text(text = stringResource(R.string.CreateGroupActivity__skip))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private interface UiCallbacks :
|
||||
RecipientPickerCallbacks.ListActions,
|
||||
RecipientPickerCallbacks.FindByUsername,
|
||||
RecipientPickerCallbacks.FindByPhoneNumber {
|
||||
|
||||
override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit
|
||||
fun onNextClicked()
|
||||
fun onUserMessageDismissed(userMessage: UserMessage)
|
||||
fun onBackPressed()
|
||||
|
||||
object Empty : UiCallbacks {
|
||||
override fun onSearchQueryChanged(query: String) = Unit
|
||||
override fun onFindByUsername() = Unit
|
||||
override fun onFindByPhoneNumber() = Unit
|
||||
override suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean = true
|
||||
override fun onPendingRecipientSelectionsConsumed() = Unit
|
||||
override fun onNextClicked() = Unit
|
||||
override fun onUserMessageDismissed(userMessage: UserMessage) = Unit
|
||||
override fun onBackPressed() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserMessagesHost(
|
||||
userMessage: UserMessage?,
|
||||
onDismiss: (UserMessage) -> Unit
|
||||
) {
|
||||
when (userMessage) {
|
||||
null -> {}
|
||||
|
||||
is UserMessage.Info.NetworkError -> Dialogs.SimpleMessageDialog(
|
||||
message = stringResource(R.string.NetworkFailure__network_error_check_your_connection_and_try_again),
|
||||
dismiss = stringResource(android.R.string.ok),
|
||||
onDismiss = { onDismiss(userMessage) }
|
||||
)
|
||||
|
||||
is UserMessage.Info.RecipientNotSignalUser -> Dialogs.SimpleMessageDialog(
|
||||
message = stringResource(R.string.NewConversationActivity__s_is_not_a_signal_user, userMessage.phone.displayText),
|
||||
dismiss = stringResource(android.R.string.ok),
|
||||
onDismiss = { onDismiss(userMessage) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun CreateGroupScreenPreview() {
|
||||
Previews.Preview {
|
||||
CreateGroupScreenUi(
|
||||
uiState = CreateGroupUiState(
|
||||
forceSplitPaneOnCompactLandscape = false,
|
||||
selectionLimits = SelectionLimits.NO_LIMITS
|
||||
),
|
||||
callbacks = UiCallbacks.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.groups.ui.creategroup
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.UserMessage.Info
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.PhoneNumber
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientRepository
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
|
||||
class CreateGroupViewModel : ViewModel() {
|
||||
private val internalUiState = MutableStateFlow(CreateGroupUiState())
|
||||
val uiState: StateFlow<CreateGroupUiState> = internalUiState.asStateFlow()
|
||||
|
||||
fun onSearchQueryChanged(query: String) {
|
||||
internalUiState.update { it.copy(searchQuery = query) }
|
||||
}
|
||||
|
||||
suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean {
|
||||
return if (id != null) true else recipientExists(phone!!)
|
||||
}
|
||||
|
||||
private suspend fun recipientExists(phone: PhoneNumber): Boolean {
|
||||
internalUiState.update { it.copy(isLookingUpRecipient = true) }
|
||||
|
||||
val lookupResult = withContext(Dispatchers.IO) {
|
||||
RecipientRepository.lookupNewE164(inputE164 = phone.value)
|
||||
}
|
||||
|
||||
return when (lookupResult) {
|
||||
is RecipientRepository.LookupResult.Success -> {
|
||||
internalUiState.update { it.copy(isLookingUpRecipient = false) }
|
||||
true
|
||||
}
|
||||
|
||||
is RecipientRepository.LookupResult.NotFound, is RecipientRepository.LookupResult.InvalidEntry -> {
|
||||
internalUiState.update {
|
||||
it.copy(
|
||||
isLookingUpRecipient = false,
|
||||
userMessage = Info.RecipientNotSignalUser(phone)
|
||||
)
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
is RecipientRepository.LookupResult.NetworkError -> {
|
||||
internalUiState.update {
|
||||
it.copy(
|
||||
isLookingUpRecipient = false,
|
||||
userMessage = Info.NetworkError
|
||||
)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSelectionChanged(newSelections: List<SelectedContact>, totalMembersCount: Int) {
|
||||
internalUiState.update {
|
||||
it.copy(
|
||||
searchQuery = "",
|
||||
newSelections = newSelections,
|
||||
totalMembersCount = totalMembersCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectRecipient(id: RecipientId) {
|
||||
internalUiState.update {
|
||||
it.copy(pendingRecipientSelections = it.pendingRecipientSelections + id)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPendingRecipientSelections() {
|
||||
internalUiState.update {
|
||||
it.copy(pendingRecipientSelections = emptySet())
|
||||
}
|
||||
}
|
||||
|
||||
fun continueToGroupDetails() {
|
||||
// TODO [jeff] pass selected recipients to AddGroupDetailsActivity
|
||||
}
|
||||
|
||||
fun clearUserMessage() {
|
||||
internalUiState.update { it.copy(userMessage = null) }
|
||||
}
|
||||
}
|
||||
|
||||
data class CreateGroupUiState(
|
||||
val forceSplitPaneOnCompactLandscape: Boolean = SignalStore.internal.forceSplitPaneOnCompactLandscape,
|
||||
val searchQuery: String = "",
|
||||
val selectionLimits: SelectionLimits = RemoteConfig.groupLimits.excludingSelf(),
|
||||
val newSelections: List<SelectedContact> = emptyList(),
|
||||
val totalMembersCount: Int = 0,
|
||||
val isLookingUpRecipient: Boolean = false,
|
||||
val pendingRecipientSelections: Set<RecipientId> = emptySet(),
|
||||
val userMessage: UserMessage? = null
|
||||
) {
|
||||
sealed interface UserMessage {
|
||||
sealed interface Info : UserMessage {
|
||||
data class RecipientNotSignalUser(val phone: PhoneNumber) : Info
|
||||
data object NetworkError : Info
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5187,11 +5187,20 @@
|
||||
|
||||
<!-- CreateGroupActivity -->
|
||||
<string name="CreateGroupActivity__skip">Skip</string>
|
||||
|
||||
<plurals name="CreateGroupActivity__d_members">
|
||||
<item quantity="one">%1$d member</item>
|
||||
<item quantity="other">%1$d members</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Create group screen title displayed when there is at least one group member selected. The placeholder is the total number of group members. -->
|
||||
<plurals name="CreateGroupActivity__s_members">
|
||||
<item quantity="one">%1$s member</item>
|
||||
<item quantity="other">%1$s members</item>
|
||||
</plurals>
|
||||
<!-- Accessibility label for navigating to the next screen in the create group flow. -->
|
||||
<string name="CreateGroupActivity__accessibility_next">Next</string>
|
||||
|
||||
<!-- ShareActivity -->
|
||||
<string name="ShareActivity__share">Share</string>
|
||||
<!-- Label to describe circular spinner button -->
|
||||
|
||||
Reference in New Issue
Block a user