Warn user when their member label will show instead of their about text.

This commit is contained in:
jeffrey-signal
2026-03-04 13:04:37 -05:00
committed by Greyson Parrelli
parent 622d9c909f
commit dc1fdffe6a
7 changed files with 448 additions and 96 deletions

View File

@@ -0,0 +1,153 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.memberlabel
import android.content.DialogInterface
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
/**
* Informs the user that their member label will be displayed in place of their About text in this group.
*/
class MemberLabelAboutOverrideSheet : ComposeBottomSheetDialogFragment() {
companion object {
const val RESULT_KEY = "member_label_about_override_result"
const val KEY_DONT_SHOW_AGAIN = "dont_show_again"
private const val FRAGMENT_TAG = "MemberLabelAboutOverrideSheet"
fun show(fragmentManager: FragmentManager) {
MemberLabelAboutOverrideSheet().show(fragmentManager, FRAGMENT_TAG)
}
}
override val peekHeightPercentage: Float = 1f
@Composable
override fun SheetContent() {
val callbacks = remember {
object : MemberLabelAboutOverrideUiCallbacks {
override fun onOkClicked() {
setFragmentResult(RESULT_KEY, bundleOf(KEY_DONT_SHOW_AGAIN to false))
dismiss()
}
override fun onDontShowAgainClicked() {
setFragmentResult(RESULT_KEY, bundleOf(KEY_DONT_SHOW_AGAIN to true))
dismiss()
}
}
}
MemberLabelAboutOverrideSheetContent(callbacks = callbacks)
}
override fun onCancel(dialog: DialogInterface) {
setFragmentResult(RESULT_KEY, bundleOf(KEY_DONT_SHOW_AGAIN to false))
super.onCancel(dialog)
}
}
@Composable
private fun MemberLabelAboutOverrideSheetContent(
callbacks: MemberLabelAboutOverrideUiCallbacks = MemberLabelAboutOverrideUiCallbacks.Empty
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(top = 4.dp, bottom = 28.dp, start = 28.dp, end = 28.dp)
.verticalScroll(rememberScrollState())
) {
BottomSheets.Handle()
Image(
painter = painterResource(R.drawable.symbol_tag_filled_64),
contentDescription = null,
modifier = Modifier
.padding(top = 24.dp)
.size(64.dp)
)
Text(
text = stringResource(R.string.MemberLabelsAboutOverride__title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = stringResource(R.string.MemberLabelsAboutOverride__body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp)
)
Buttons.LargeTonal(
onClick = callbacks::onOkClicked,
modifier = Modifier
.padding(top = 64.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = stringResource(android.R.string.ok))
}
TextButton(
onClick = callbacks::onDontShowAgainClicked,
modifier = Modifier.padding(top = 16.dp)
) {
Text(text = stringResource(R.string.ConversationFragment_dont_show_again))
}
}
}
private interface MemberLabelAboutOverrideUiCallbacks {
fun onOkClicked()
fun onDontShowAgainClicked()
object Empty : MemberLabelAboutOverrideUiCallbacks {
override fun onOkClicked() = Unit
override fun onDontShowAgainClicked() = Unit
}
}
@AllDevicePreviews
@Composable
private fun MemberLabelAboutOverrideSheetPreview() = Previews.Preview {
MemberLabelAboutOverrideSheetContent()
}
@DayNightPreviews
@Composable
private fun MemberLabelAboutOverrideSheetDarkPreview() = Previews.Preview {
MemberLabelAboutOverrideSheetContent()
}

View File

@@ -5,6 +5,8 @@
package org.thoughtcrime.securesms.groups.memberlabel
import android.os.Bundle
import android.view.View
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -89,6 +91,16 @@ class MemberLabelFragment : ComposeFragment(), ReactWithAnyEmojiBottomSheetDialo
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
childFragmentManager.setFragmentResultListener(MemberLabelAboutOverrideSheet.RESULT_KEY, viewLifecycleOwner) { _, resultData ->
viewModel.onAboutOverrideSheetDismissed(
dontShowAgain = resultData.getBoolean(MemberLabelAboutOverrideSheet.KEY_DONT_SHOW_AGAIN)
)
}
}
@Composable
override fun FragmentContent() {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -111,6 +123,13 @@ class MemberLabelFragment : ComposeFragment(), ReactWithAnyEmojiBottomSheetDialo
val networkErrorMessage = stringResource(R.string.GroupMemberLabel__error_cant_save_no_network)
LaunchedEffect(uiState.showAboutOverrideSheet) {
if (uiState.showAboutOverrideSheet) {
MemberLabelAboutOverrideSheet.show(childFragmentManager)
viewModel.onAboutOverrideSheetShown()
}
}
LaunchedEffect(uiState.saveState) {
when (uiState.saveState) {
is SaveState.Success -> {

View File

@@ -19,6 +19,8 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.UiHintValues
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.RemoteConfig
@@ -29,7 +31,8 @@ import org.whispersystems.signalservice.api.NetworkResult
*/
class MemberLabelRepository private constructor(
private val context: Context = AppDependencies.application,
private val groupsTable: GroupTable = SignalDatabase.groups
private val groupsTable: GroupTable = SignalDatabase.groups,
private val uiHints: UiHintValues = SignalStore.uiHints
) {
companion object {
@JvmStatic
@@ -105,9 +108,7 @@ class MemberLabelRepository private constructor(
/**
* Computes the sender [NameColor] for a recipient as seen by other group members.
*/
suspend fun getSenderNameColor(groupId: GroupId.V2, recipientId: RecipientId): NameColor = withContext(Dispatchers.IO) {
val recipient = getRecipient(recipientId)
suspend fun getSenderNameColor(groupId: GroupId.V2, recipient: Recipient): NameColor = withContext(Dispatchers.IO) {
val groupMemberIds = groupsTable
.getGroupMembers(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF)
.mapNotNull { it.serviceId.orNull() }
@@ -128,6 +129,14 @@ class MemberLabelRepository private constructor(
GroupManager.updateMemberLabel(context, groupId, sanitizedLabel.text, sanitizedLabel.emoji.orEmpty())
}
}
fun hasDismissedMemberLabelAboutOverrideWarning(): Boolean {
return uiHints.hasDismissedMemberLabelAboutOverrideWarning()
}
fun markMemberLabelAboutOverrideWarningDismissed() {
uiHints.markMemberLabelAboutOverrideWarningDismissed()
}
}
private fun MemberLabel.sanitized(): MemberLabel = this.copy(

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.util.StringUtil
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.isNotNullOrBlank
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException
@@ -25,7 +26,8 @@ import org.whispersystems.signalservice.api.NetworkResult
class MemberLabelViewModel(
private val memberLabelRepo: MemberLabelRepository = MemberLabelRepository.instance,
private val groupId: GroupId.V2,
private val recipientId: RecipientId
private val recipientId: RecipientId,
private val sanitizeEmoji: (String) -> String? = MemberLabel::sanitizeEmoji
) : ViewModel() {
private var originalLabelEmoji: String = ""
@@ -40,16 +42,17 @@ class MemberLabelViewModel(
private fun loadInitialState() {
viewModelScope.launch(SignalDispatchers.IO) {
val memberLabel = memberLabelRepo.getLabel(groupId, recipientId)
val recipient = memberLabelRepo.getRecipient(recipientId)
val memberLabel = memberLabelRepo.getLabel(groupId, recipient)
originalLabelEmoji = memberLabel?.emoji.orEmpty()
originalLabelText = memberLabel?.text.orEmpty()
internalUiState.update {
it.copy(
recipient = memberLabelRepo.getRecipient(recipientId),
recipient = recipient,
labelEmoji = originalLabelEmoji,
labelText = originalLabelText,
senderNameColor = memberLabelRepo.getSenderNameColor(groupId, recipientId)
senderNameColor = memberLabelRepo.getSenderNameColor(groupId, recipient)
)
}
}
@@ -85,7 +88,8 @@ class MemberLabelViewModel(
}
private fun hasChanges(labelEmoji: String, labelText: String): Boolean {
return labelEmoji != originalLabelEmoji || MemberLabel.sanitizeLabelText(labelText) != originalLabelText
return sanitizeEmoji(labelEmoji).orEmpty() != originalLabelEmoji ||
MemberLabel.sanitizeLabelText(labelText) != originalLabelText
}
fun save() {
@@ -107,14 +111,26 @@ class MemberLabelViewModel(
)
)
val newSaveState: SaveState = when (result) {
is NetworkResult.Success -> SaveState.Success
when (result) {
is NetworkResult.Success -> {
val isLabelCleared = currentState.sanitizedLabelText.isEmpty() && currentState.labelEmoji.isEmpty()
val selfHasAbout = currentState.recipient?.combinedAboutAndEmoji.isNotNullOrBlank()
val showOverrideSheet = !isLabelCleared && selfHasAbout && !memberLabelRepo.hasDismissedMemberLabelAboutOverrideWarning()
is NetworkResult.NetworkError<*> -> SaveState.NetworkError
internalUiState.update {
if (showOverrideSheet) {
it.copy(showAboutOverrideSheet = true)
} else {
it.copy(saveState = SaveState.Success)
}
}
}
is NetworkResult.NetworkError<*> -> internalUiState.update { it.copy(saveState = SaveState.NetworkError) }
is NetworkResult.ApplicationError<*> -> {
if (result.throwable is GroupInsufficientRightsException) {
SaveState.InsufficientRights
internalUiState.update { it.copy(saveState = SaveState.InsufficientRights) }
} else {
throw result.throwable
}
@@ -122,10 +138,6 @@ class MemberLabelViewModel(
is NetworkResult.StatusCodeError<*> -> throw result.exception
}
internalUiState.update {
it.copy(saveState = newSaveState)
}
}
}
@@ -134,6 +146,21 @@ class MemberLabelViewModel(
it.copy(saveState = null)
}
}
fun onAboutOverrideSheetShown() {
internalUiState.update {
it.copy(showAboutOverrideSheet = false)
}
}
fun onAboutOverrideSheetDismissed(dontShowAgain: Boolean) {
if (dontShowAgain) {
memberLabelRepo.markMemberLabelAboutOverrideWarningDismissed()
}
internalUiState.update {
it.copy(saveState = SaveState.Success)
}
}
}
data class MemberLabelUiState(
@@ -142,7 +169,8 @@ data class MemberLabelUiState(
val recipient: Recipient? = null,
val senderNameColor: NameColor? = null,
val hasChanges: Boolean = false,
val saveState: SaveState? = null
val saveState: SaveState? = null,
val showAboutOverrideSheet: Boolean = false
) {
val sanitizedLabelText: String get() = MemberLabel.sanitizeLabelText(labelText)

View File

@@ -10,28 +10,29 @@ public class UiHintValues extends SignalStoreValues {
private static final int NEVER_DISPLAY_PULL_TO_FILTER_TIP_THRESHOLD = 3;
private static final int HAS_SEEN_PINNED_MESSAGE_SHEET_THRESHOLD = 3;
private static final String HAS_SEEN_GROUP_SETTINGS_MENU_TOAST = "uihints.has_seen_group_settings_menu_toast";
private static final String HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE = "uihints.has_confirmed_delete_for_everyone_once";
private static final String HAS_SET_OR_SKIPPED_USERNAME_CREATION = "uihints.has_set_or_skipped_username_creation";
private static final String NEVER_DISPLAY_PULL_TO_FILTER_TIP = "uihints.never_display_pull_to_filter_tip";
private static final String HAS_SEEN_SCHEDULED_MESSAGES_INFO_ONCE = "uihints.has_seen_scheduled_messages_info_once";
private static final String HAS_SEEN_SAFETY_NUMBER_NUX = "uihints.has_seen_safety_number_nux";
private static final String DECLINED_NOTIFICATION_LOGS_PROMPT = "uihints.declined_notification_logs";
private static final String LAST_NOTIFICATION_LOGS_PROMPT_TIME = "uihints.last_notification_logs_prompt";
private static final String DISMISSED_BATTERY_SAVER_PROMPT = "uihints.declined_battery_saver_prompt";
private static final String LAST_BATTERY_SAVER_PROMPT = "uihints.last_battery_saver_prompt";
private static final String LAST_CRASH_PROMPT = "uihints.last_crash_prompt";
private static final String HAS_COMPLETED_USERNAME_ONBOARDING = "uihints.has_completed_username_onboarding";
private static final String HAS_SEEN_DOUBLE_TAP_EDIT_EDUCATION_SHEET = "uihints.has_seen_double_tap_edit_education_sheet";
private static final String DISMISSED_CONTACTS_PERMISSION_BANNER = "uihints.dismissed_contacts_permission_banner";
private static final String HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET = "uihints.has_seen_delete_sync_education_sheet";
private static final String LAST_SUPPORT_VERSION_SEEN = "uihints.last_support_version_seen";
private static final String HAS_EVER_ENABLED_REMOTE_BACKUPS = "uihints.has_ever_enabled_remote_backups";
private static final String HAS_SEEN_CHAT_FOLDERS_EDUCATION_SHEET = "uihints.has_seen_chat_folders_education_sheet";
private static final String HAS_SEEN_LINK_DEVICE_QR_EDUCATION_SHEET = "uihints.has_seen_link_device_qr_education_sheet";
private static final String HAS_DISMISSED_SAVE_STORAGE_WARNING = "uihints.has_dismissed_save_storage_warning";
private static final String HAS_SEEN_PINNED_MESSAGE_SHEET = "uihints.has_seen_pinned_message_sheet";
private static final String HAS_SEEN_VERIFY_AUTO_SHEET = "uihints.has_seen_verify_auto_sheet";
private static final String HAS_SEEN_GROUP_SETTINGS_MENU_TOAST = "uihints.has_seen_group_settings_menu_toast";
private static final String HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE = "uihints.has_confirmed_delete_for_everyone_once";
private static final String HAS_SET_OR_SKIPPED_USERNAME_CREATION = "uihints.has_set_or_skipped_username_creation";
private static final String NEVER_DISPLAY_PULL_TO_FILTER_TIP = "uihints.never_display_pull_to_filter_tip";
private static final String HAS_SEEN_SCHEDULED_MESSAGES_INFO_ONCE = "uihints.has_seen_scheduled_messages_info_once";
private static final String HAS_SEEN_SAFETY_NUMBER_NUX = "uihints.has_seen_safety_number_nux";
private static final String DECLINED_NOTIFICATION_LOGS_PROMPT = "uihints.declined_notification_logs";
private static final String LAST_NOTIFICATION_LOGS_PROMPT_TIME = "uihints.last_notification_logs_prompt";
private static final String DISMISSED_BATTERY_SAVER_PROMPT = "uihints.declined_battery_saver_prompt";
private static final String LAST_BATTERY_SAVER_PROMPT = "uihints.last_battery_saver_prompt";
private static final String LAST_CRASH_PROMPT = "uihints.last_crash_prompt";
private static final String HAS_COMPLETED_USERNAME_ONBOARDING = "uihints.has_completed_username_onboarding";
private static final String HAS_SEEN_DOUBLE_TAP_EDIT_EDUCATION_SHEET = "uihints.has_seen_double_tap_edit_education_sheet";
private static final String DISMISSED_CONTACTS_PERMISSION_BANNER = "uihints.dismissed_contacts_permission_banner";
private static final String HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET = "uihints.has_seen_delete_sync_education_sheet";
private static final String LAST_SUPPORT_VERSION_SEEN = "uihints.last_support_version_seen";
private static final String HAS_EVER_ENABLED_REMOTE_BACKUPS = "uihints.has_ever_enabled_remote_backups";
private static final String HAS_SEEN_CHAT_FOLDERS_EDUCATION_SHEET = "uihints.has_seen_chat_folders_education_sheet";
private static final String HAS_SEEN_LINK_DEVICE_QR_EDUCATION_SHEET = "uihints.has_seen_link_device_qr_education_sheet";
private static final String HAS_DISMISSED_SAVE_STORAGE_WARNING = "uihints.has_dismissed_save_storage_warning";
private static final String HAS_SEEN_PINNED_MESSAGE_SHEET = "uihints.has_seen_pinned_message_sheet";
private static final String HAS_SEEN_VERIFY_AUTO_SHEET = "uihints.has_seen_verify_auto_sheet";
private static final String HAS_DISMISSED_MEMBER_LABEL_ABOUT_OVERRIDE_WARNING = "uihints.has_dismissed_member_label_about_override_warning";
UiHintValues(@NonNull KeyValueStore store) {
super(store);
@@ -241,4 +242,12 @@ public class UiHintValues extends SignalStoreValues {
public void setSeenVerifyAutomaticallySheet() {
putBoolean(HAS_SEEN_VERIFY_AUTO_SHEET, true);
}
public boolean hasDismissedMemberLabelAboutOverrideWarning() {
return getBoolean(HAS_DISMISSED_MEMBER_LABEL_ABOUT_OVERRIDE_WARNING, false);
}
public void markMemberLabelAboutOverrideWarningDismissed() {
putBoolean(HAS_DISMISSED_MEMBER_LABEL_ABOUT_OVERRIDE_WARNING, true);
}
}