Add group member label editing screen.

This commit is contained in:
jeffrey-signal
2026-01-30 11:53:07 -05:00
committed by Greyson Parrelli
parent bc592cc4e2
commit 99d9c670b6
9 changed files with 505 additions and 11 deletions

View File

@@ -100,6 +100,7 @@ import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.ExpirationUtil
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -805,6 +806,18 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
)
if (RemoteConfig.sendMemberLabels) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_member_label),
icon = DSLSettingsIcon.from(R.drawable.symbol_tag_24),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
navController.safeNavigate(action)
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__requests_and_invites),
icon = DSLSettingsIcon.from(R.drawable.ic_update_group_add_16),

View File

@@ -0,0 +1,270 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.memberlabel
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.navArgs
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ClearableTextField
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.util.isNotNullOrBlank
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveState
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.viewModel
/**
* Screen for editing a user's group-specific label and emoji.
*/
class MemberLabelFragment : ComposeFragment(), ReactWithAnyEmojiBottomSheetDialogFragment.Callback {
companion object {
private const val EMOJI_PICKER_DIALOG_TAG = "emoji_picker_dialog"
}
private val args: MemberLabelFragmentArgs by navArgs()
private val viewModel: MemberLabelViewModel by viewModel {
MemberLabelViewModel(
groupId = (args.groupId as GroupId).requireV2(),
recipientId = Recipient.self().id
)
}
@Composable
override fun FragmentContent() {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val callbacks = remember {
object : UiCallbacks {
override fun onClosePressed() {
backPressedDispatcher?.onBackPressed()
}
override fun onLabelEmojiChanged(emoji: String) = viewModel.onLabelEmojiChanged(emoji)
override fun onLabelTextChanged(text: String) = viewModel.onLabelTextChanged(text)
override fun onSetEmojiClicked() = showEmojiPicker()
override fun onClearLabelClicked() = viewModel.clearLabel()
override fun onSaveClicked() = viewModel.save()
}
}
LaunchedEffect(uiState.saveState) {
if (uiState.saveState is SaveState.Success) {
backPressedDispatcher?.onBackPressed()
viewModel.onSaveStateConsumed()
}
}
MemberLabelScreenUi(
state = uiState,
callbacks = callbacks
)
}
private fun showEmojiPicker() {
ReactWithAnyEmojiBottomSheetDialogFragment.createForAboutSelection()
.show(childFragmentManager, EMOJI_PICKER_DIALOG_TAG)
}
override fun onReactWithAnyEmojiSelected(emoji: String) = viewModel.onLabelEmojiChanged(emoji)
override fun onReactWithAnyEmojiDialogDismissed() = Unit
}
@Composable
private fun MemberLabelScreenUi(
state: MemberLabelUiState,
callbacks: UiCallbacks
) {
Scaffolds.Settings(
title = stringResource(R.string.GroupMemberLabel__title),
onNavigationClick = callbacks::onClosePressed,
navigationIcon = SignalIcons.X.imageVector,
navigationContentDescription = stringResource(R.string.GroupMemberLabel__accessibility_close_screen)
) { paddingValues ->
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
}
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
LabelTextField(
labelEmoji = state.labelEmoji,
labelText = state.labelText,
remainingCharacters = state.remainingCharacters,
onLabelTextChange = callbacks::onLabelTextChanged,
onEmojiChange = callbacks::onSetEmojiClicked,
onClear = callbacks::onClearLabelClicked,
onSave = callbacks::onSaveClicked,
modifier = Modifier
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp)
.focusRequester(focusRequester)
)
Spacer(modifier = Modifier.weight(1f))
SaveButton(
enabled = state.isSaveEnabled,
onClick = callbacks::onSaveClicked,
modifier = Modifier
.align(Alignment.End)
.padding(24.dp)
)
}
}
}
@Composable
private fun LabelTextField(
labelEmoji: String?,
labelText: String,
remainingCharacters: Int,
onLabelTextChange: (String) -> Unit,
onEmojiChange: () -> Unit,
onClear: () -> Unit,
onSave: () -> Unit,
modifier: Modifier = Modifier
) {
ClearableTextField(
value = labelText,
onValueChange = onLabelTextChange,
onClear = onClear,
clearContentDescription = stringResource(R.string.GroupMemberLabel__accessibility_clear_label),
placeholder = { Text(stringResource(R.string.GroupMemberLabel__label_text_placeholder)) },
leadingIcon = {
EmojiPickerButton(
selectedEmoji = labelEmoji,
onEmojiSelected = onEmojiChange
)
},
enabled = true,
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { onSave() }),
charactersRemaining = remainingCharacters,
countdownConfig = ClearableTextField.CountdownConfig(displayThreshold = 9, warnThreshold = 5),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = modifier
)
}
@Composable
private fun EmojiPickerButton(
onEmojiSelected: () -> Unit,
selectedEmoji: String?
) {
IconButton(
onClick = onEmojiSelected
) {
if (selectedEmoji.isNotNullOrBlank()) {
Text(
text = selectedEmoji,
style = MaterialTheme.typography.bodyLarge
)
} else {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_emoji_plus_24),
contentDescription = stringResource(R.string.GroupMemberLabel__accessibility_select_emoji),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
}
}
}
@Composable
private fun SaveButton(
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Buttons.LargeTonal(
onClick = onClick,
enabled = enabled,
modifier = modifier
) {
Text(text = stringResource(R.string.GroupMemberLabel__save))
}
}
private interface UiCallbacks {
fun onClosePressed()
fun onLabelEmojiChanged(emoji: String)
fun onLabelTextChanged(text: String)
fun onSetEmojiClicked()
fun onClearLabelClicked()
fun onSaveClicked()
object Empty : UiCallbacks {
override fun onClosePressed() = Unit
override fun onLabelEmojiChanged(emoji: String) = Unit
override fun onLabelTextChanged(text: String) = Unit
override fun onSetEmojiClicked() = Unit
override fun onClearLabelClicked() = Unit
override fun onSaveClicked() = Unit
}
}
@AllDevicePreviews
@Composable
private fun MemberLabelScreenPreview() {
Previews.Preview {
MemberLabelScreenUi(
state = MemberLabelUiState(
labelEmoji = "⛑️",
labelText = "Vet Coordinator"
),
callbacks = UiCallbacks.Empty
)
}
}

View File

@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Handles the retrieval and modification of group member labels.
@@ -41,14 +42,11 @@ class MemberLabelRepository(
* Sets the group member label for the current user.
*/
suspend fun setLabel(label: MemberLabel): Unit = withContext(Dispatchers.IO) {
GroupManager.updateMemberLabel(context, groupId, label.text, label.emoji ?: "")
}
if (!RemoteConfig.sendMemberLabels) {
throw IllegalStateException("Set member label not allowed due to remote config.")
}
/**
* Clears the group member label for the current user.
*/
suspend fun removeLabel(): Unit = withContext(Dispatchers.IO) {
GroupManager.updateMemberLabel(context, groupId, "", "")
GroupManager.updateMemberLabel(context, groupId, label.text, label.emoji.orEmpty())
}
}

View File

@@ -0,0 +1,145 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.memberlabel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.util.concurrent.SignalDispatchers
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveState
import org.thoughtcrime.securesms.recipients.RecipientId
private const val MIN_LABEL_TEXT_LENGTH = 1
private const val MAX_LABEL_TEXT_LENGTH = 24
class MemberLabelViewModel(
private val memberLabelRepo: MemberLabelRepository,
private val recipientId: RecipientId
) : ViewModel() {
constructor(
groupId: GroupId.V2,
recipientId: RecipientId
) : this(
memberLabelRepo = MemberLabelRepository(groupId = groupId),
recipientId = recipientId
)
private var originalLabelEmoji: String = ""
private var originalLabelText: String = ""
private val internalUiState = MutableStateFlow(MemberLabelUiState())
val uiState: StateFlow<MemberLabelUiState> = internalUiState.asStateFlow()
init {
loadExistingLabel()
}
private fun loadExistingLabel() {
viewModelScope.launch(SignalDispatchers.IO) {
val memberLabel = memberLabelRepo.getLabel(recipientId)
originalLabelEmoji = memberLabel?.emoji.orEmpty()
originalLabelText = memberLabel?.text.orEmpty()
internalUiState.update {
it.copy(
labelEmoji = originalLabelEmoji,
labelText = originalLabelText
)
}
}
}
fun onLabelEmojiChanged(emoji: String) {
internalUiState.update {
it.copy(
labelEmoji = emoji,
hasChanges = hasChanges(emoji, it.labelText)
)
}
}
fun onLabelTextChanged(text: String) {
val sanitizedText = text.take(MAX_LABEL_TEXT_LENGTH)
internalUiState.update {
it.copy(
labelText = sanitizedText,
hasChanges = hasChanges(labelEmoji = it.labelEmoji, labelText = sanitizedText)
)
}
}
fun clearLabel() {
internalUiState.update {
it.copy(
labelEmoji = "",
labelText = "",
hasChanges = hasChanges(labelEmoji = "", labelText = "")
)
}
}
private fun hasChanges(labelEmoji: String, labelText: String): Boolean {
return labelEmoji != originalLabelEmoji || labelText != originalLabelText
}
fun save() {
if (!internalUiState.value.isSaveEnabled) {
return
}
viewModelScope.launch(SignalDispatchers.IO) {
internalUiState.update {
it.copy(saveState = SaveState.InProgress)
}
val currentState = internalUiState.value
memberLabelRepo.setLabel(
label = MemberLabel(
emoji = currentState.labelEmoji.ifEmpty { null },
text = currentState.labelText
)
)
internalUiState.update {
it.copy(saveState = SaveState.Success)
}
}
}
fun onSaveStateConsumed() {
internalUiState.update {
it.copy(saveState = null)
}
}
}
data class MemberLabelUiState(
val labelEmoji: String = "",
val labelText: String = "",
val hasChanges: Boolean = false,
val saveState: SaveState? = null
) {
val remainingCharacters: Int
get() = MAX_LABEL_TEXT_LENGTH - labelText.length
val isSaveEnabled: Boolean
get() {
val isCleared = labelText.isEmpty() && labelEmoji.isEmpty()
val hasValidLabel = labelText.length >= MIN_LABEL_TEXT_LENGTH
return hasChanges && (hasValidLabel || isCleared) && saveState != SaveState.InProgress
}
sealed interface SaveState {
data object InProgress : SaveState
data object Success : SaveState
}
}

View File

@@ -1249,5 +1249,16 @@ object RemoteConfig {
hotSwappable = true
)
/**
* Whether to enable modifying group member labels.
*/
@JvmStatic
@get:JvmName("sendMemberLabels")
val sendMemberLabels: Boolean by remoteBoolean(
key = "android.sendMemberLabels",
defaultValue = false,
hotSwappable = true
)
// endregion
}