diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 4aa78ff475..ff4c1c2468 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -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), diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt new file mode 100644 index 0000000000..ef4d1d91d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt index e206a168f2..5d9d09925c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt @@ -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()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModel.kt new file mode 100644 index 0000000000..9686b654a5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModel.kt @@ -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 = 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index 2fe9d9bcdd..a33ea593fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -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 } diff --git a/app/src/main/res/drawable/symbol_tag_24.xml b/app/src/main/res/drawable/symbol_tag_24.xml new file mode 100644 index 0000000000..595610c9ec --- /dev/null +++ b/app/src/main/res/drawable/symbol_tag_24.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/navigation/conversation_settings.xml b/app/src/main/res/navigation/conversation_settings.xml index 403a6e039a..d190183a47 100644 --- a/app/src/main/res/navigation/conversation_settings.xml +++ b/app/src/main/res/navigation/conversation_settings.xml @@ -102,6 +102,20 @@ app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58013b5919..1fa1abb6fb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5973,6 +5973,8 @@ Permissions Requests & invites Group link + + Member Label Add as a contact Unmute @@ -9273,5 +9275,20 @@ Check your connection and try again. + + Member label + + Add your role + + Preview + + Save + + Select emoji + + Close screen + + Clear label + diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/ClearableTextField.kt b/core/ui/src/main/java/org/signal/core/ui/compose/ClearableTextField.kt index f3ab32a48c..d9f0a2c2c5 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/ClearableTextField.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/ClearableTextField.kt @@ -67,6 +67,7 @@ fun ClearableTextField( keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = false, clearable: Boolean = true, + onClear: () -> Unit = { onValueChange("") }, charactersRemainingBeforeLimit: Int = Int.MAX_VALUE, countdownConfig: ClearableTextField.CountdownConfig? = null, colors: TextFieldColors = defaultTextFieldColors() @@ -84,6 +85,7 @@ fun ClearableTextField( keyboardActions = keyboardActions, singleLine = singleLine, clearable = clearable, + onClear = onClear, charactersRemaining = charactersRemainingBeforeLimit, countdownConfig = countdownConfig, colors = colors @@ -104,11 +106,13 @@ fun ClearableTextField( enabled: Boolean = true, textStyle: TextStyle = LocalTextStyle.current, label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, leadingIcon: @Composable (() -> Unit)? = null, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = false, clearable: Boolean = true, + onClear: () -> Unit = { onValueChange("") }, charactersRemaining: Int = Int.MAX_VALUE, countdownConfig: ClearableTextField.CountdownConfig? = null, colors: TextFieldColors = defaultTextFieldColors() @@ -119,7 +123,7 @@ fun ClearableTextField( val clearButton: @Composable () -> Unit = { ClearButton( visible = focused, - onClick = { onValueChange("") }, + onClick = onClear, contentDescription = clearContentDescription ) } @@ -130,6 +134,7 @@ fun ClearableTextField( onValueChange = onValueChange, textStyle = textStyle, label = label, + placeholder = placeholder, enabled = enabled, singleLine = singleLine, keyboardActions = keyboardActions, @@ -140,7 +145,11 @@ fun ClearableTextField( colors = colors, leadingIcon = leadingIcon, trailingIcon = if (clearable) clearButton else null, - contentPadding = TextFieldDefaults.contentPaddingWithLabel(end = if (displayCountdown) 48.dp else 16.dp) + contentPadding = if (label == null) { + TextFieldDefaults.contentPaddingWithoutLabel(end = if (displayCountdown) 48.dp else 16.dp) + } else { + TextFieldDefaults.contentPaddingWithLabel(end = if (displayCountdown) 48.dp else 16.dp) + } ) AnimatedVisibility( @@ -170,9 +179,9 @@ private fun ClearButton( onClick = onClick ) { Icon( - painter = SignalIcons.XCircleFill.painter, + painter = SignalIcons.X.painter, contentDescription = contentDescription, - tint = MaterialTheme.colorScheme.outline + tint = MaterialTheme.colorScheme.onSurface ) } }