From dc1fdffe6a516636c8b34f48dfccf01a9d0429eb Mon Sep 17 00:00:00 2001 From: jeffrey-signal Date: Wed, 4 Mar 2026 13:04:37 -0500 Subject: [PATCH] Warn user when their member label will show instead of their about text. --- .../MemberLabelAboutOverrideSheet.kt | 153 +++++++++++ .../groups/memberlabel/MemberLabelFragment.kt | 19 ++ .../memberlabel/MemberLabelRepository.kt | 17 +- .../memberlabel/MemberLabelViewModel.kt | 56 +++- .../securesms/keyvalue/UiHintValues.java | 53 ++-- app/src/main/res/values/strings.xml | 5 + .../memberlabel/MemberLabelViewModelTest.kt | 241 ++++++++++++++---- 7 files changed, 448 insertions(+), 96 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelAboutOverrideSheet.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelAboutOverrideSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelAboutOverrideSheet.kt new file mode 100644 index 0000000000..98a0e93dfe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelAboutOverrideSheet.kt @@ -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() +} 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 index 2ffc8731c5..6a4c61a533 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt @@ -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 -> { 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 fae853669e..fe2233156f 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 @@ -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( 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 index af792e05d4..ea094038ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModel.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHintValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHintValues.java index e0724869b7..19efeea2c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHintValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHintValues.java @@ -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); + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f5771121e..ac7b25e527 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9495,5 +9495,10 @@ Edit your label + + Member label display + + In this group, your Member Label will be displayed beside your photo in place of your About. + diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt index 269137cbf6..3a36df99ee 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt @@ -7,7 +7,9 @@ package org.thoughtcrime.securesms.groups.memberlabel import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -21,6 +23,7 @@ import org.thoughtcrime.securesms.conversation.colors.NameColor import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveState +import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule import org.whispersystems.signalservice.api.NetworkResult @@ -41,13 +44,21 @@ class MemberLabelViewModelTest { fun setUp() { coEvery { memberLabelRepo.getRecipient(any()) } returns mockk(relaxed = true) coEvery { memberLabelRepo.getSenderNameColor(any(), any()) } returns NameColor(0, 0) + every { memberLabelRepo.hasDismissedMemberLabelAboutOverrideWarning() } returns false } + private fun createViewModel() = MemberLabelViewModel( + memberLabelRepo = memberLabelRepo, + groupId = groupId, + recipientId = recipientId, + sanitizeEmoji = { emoji -> emoji.takeIf { it.isNotBlank() } } + ) + @Test fun `isSaveEnabled returns true when label text is different from the original value`() { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged("Modified") assertTrue(viewModel.uiState.value.isSaveEnabled) @@ -55,9 +66,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns false when label text is the same as the original value`() { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged("Original") assertFalse(viewModel.uiState.value.isSaveEnabled) @@ -65,9 +76,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns true when label text is valid and the emoji is different from the original value`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Label") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Label") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelEmojiChanged("🎉") assertTrue(viewModel.uiState.value.isSaveEnabled) @@ -75,18 +86,18 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns false when the label and emoji are not changed`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Label") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Label") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() assertFalse(viewModel.uiState.value.isSaveEnabled) } @Test fun `isSaveEnabled returns false when the label and emoji are changed to the original value`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelEmojiChanged("🫢") viewModel.onLabelTextChanged("Modified") @@ -99,9 +110,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns false when label is too short`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Label") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Label") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged("") viewModel.onLabelEmojiChanged("🎉") @@ -110,9 +121,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns true when clearLabel is called with existing label and emoji`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.clearLabel() assertTrue(viewModel.uiState.value.isSaveEnabled) @@ -120,9 +131,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns true when clearLabel is called with existing label without emoji`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.clearLabel() assertTrue(viewModel.uiState.value.isSaveEnabled) @@ -130,9 +141,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns false when clearLabel is called with no existing label`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.clearLabel() assertFalse(viewModel.uiState.value.isSaveEnabled) @@ -140,9 +151,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns true when both emoji and label are modified`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged("New Label") viewModel.onLabelEmojiChanged("🚀") @@ -151,9 +162,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns false when only emoji is changed without an existing label`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelEmojiChanged("🎉") assertFalse(viewModel.uiState.value.isSaveEnabled) @@ -161,9 +172,9 @@ class MemberLabelViewModelTest { @Test fun `save does not call setLabel when isSaveEnabled is false`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Label") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Label") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.save() coVerify(exactly = 0) { memberLabelRepo.setLabel(groupId, any()) } @@ -171,9 +182,9 @@ class MemberLabelViewModelTest { @Test fun `save does not call setLabel when label is less than 1 character`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Label") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Label") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged("") viewModel.onLabelEmojiChanged("🎉") viewModel.save() @@ -183,10 +194,10 @@ class MemberLabelViewModelTest { @Test fun `save calls setLabel with truncated label when label exceeds max length`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null coEvery { memberLabelRepo.setLabel(any(), any()) } returns NetworkResult.Success(Unit) - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged("A".repeat(30)) viewModel.save() @@ -200,9 +211,9 @@ class MemberLabelViewModelTest { @Test fun `save does not call setLabel when emoji is set with no label`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelEmojiChanged("🎉") viewModel.save() @@ -211,10 +222,10 @@ class MemberLabelViewModelTest { @Test fun `save calls setLabel when label change is valid`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") coEvery { memberLabelRepo.setLabel(any(), any()) } returns NetworkResult.Success(Unit) - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged("New Label") viewModel.onLabelEmojiChanged("🎉") viewModel.save() @@ -226,10 +237,10 @@ class MemberLabelViewModelTest { @Test fun `save calls setLabel with cleared values when clearLabel is called`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Original") coEvery { memberLabelRepo.setLabel(any(), any()) } returns NetworkResult.Success(Unit) - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.clearLabel() viewModel.save() @@ -240,9 +251,9 @@ class MemberLabelViewModelTest { @Test fun `onLabelTextChanged counts emoji as single grapheme`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() val emoji = "\uD83C\uDF89" // 🎉 viewModel.onLabelTextChanged(emoji.repeat(30)) @@ -251,9 +262,9 @@ class MemberLabelViewModelTest { @Test fun `remainingCharacters counts emoji as single grapheme`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() val emoji = "\uD83C\uDF89" // 🎉 viewModel.onLabelTextChanged(emoji.repeat(10)) @@ -262,9 +273,9 @@ class MemberLabelViewModelTest { @Test fun `remainingCharacters counts mixed ascii and emoji correctly`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged("Hello \uD83C\uDF89") // "Hello 🎉" = 7 graphemes assertEquals(17, viewModel.uiState.value.remainingCharacters) @@ -272,9 +283,9 @@ class MemberLabelViewModelTest { @Test fun `onLabelTextChanged does not truncate text within grapheme limit`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged("Short label") assertEquals("Short label", viewModel.uiState.value.labelText) @@ -282,9 +293,9 @@ class MemberLabelViewModelTest { @Test fun `onLabelTextChanged truncates at exactly 24 graphemes with emoji`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() val input = "A".repeat(23) + "\uD83C\uDF89\uD83C\uDF89" // 25 graphemes viewModel.onLabelTextChanged(input) @@ -294,9 +305,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns false when the only change is trailing whitespace`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged("Original ") assertFalse(viewModel.uiState.value.isSaveEnabled) @@ -304,9 +315,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns false when the only change is leading whitespace`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged(" Original") assertFalse(viewModel.uiState.value.isSaveEnabled) @@ -314,9 +325,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns true when text differs beyond whitespace`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged(" Modified ") assertTrue(viewModel.uiState.value.isSaveEnabled) @@ -324,10 +335,10 @@ class MemberLabelViewModelTest { @Test fun `save sets saveState to Success when setLabel returns NetworkResult Success`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") coEvery { memberLabelRepo.setLabel(any(), any()) } returns NetworkResult.Success(Unit) - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged("New Label") viewModel.save() @@ -336,10 +347,10 @@ class MemberLabelViewModelTest { @Test fun `save sets saveState to NetworkError when setLabel returns NetworkResult NetworkError`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") coEvery { memberLabelRepo.setLabel(any(), any()) } returns NetworkResult.NetworkError(IOException("Network failure")) - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged("New Label") viewModel.save() @@ -348,13 +359,131 @@ class MemberLabelViewModelTest { @Test fun `save sets saveState to InsufficientRights when setLabel returns ApplicationError with GroupInsufficientRightsException`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") coEvery { memberLabelRepo.setLabel(any(), any()) } returns NetworkResult.ApplicationError(GroupInsufficientRightsException(RuntimeException("Insufficient rights (test)"))) - val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val viewModel = createViewModel() viewModel.onLabelTextChanged("New Label") viewModel.save() assertEquals(SaveState.InsufficientRights, viewModel.uiState.value.saveState) } + + @Test + fun `save shows about override warning when recipient has about text and the warning hasn't been dismissed`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getRecipient(any()) } returns Recipient(about = "Some about text") + coEvery { memberLabelRepo.setLabel(any(), any()) } returns NetworkResult.Success(Unit) + every { memberLabelRepo.hasDismissedMemberLabelAboutOverrideWarning() } returns false + + val viewModel = createViewModel() + viewModel.onLabelTextChanged("New Label") + viewModel.save() + + assertTrue(viewModel.uiState.value.showAboutOverrideSheet) + } + + @Test + fun `save shows about override warning when recipient has about emoji and the warning hasn't been dismissed`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getRecipient(any()) } returns Recipient(about = null, aboutEmoji = "😎") + coEvery { memberLabelRepo.setLabel(any(), any()) } returns NetworkResult.Success(Unit) + every { memberLabelRepo.hasDismissedMemberLabelAboutOverrideWarning() } returns false + + val viewModel = createViewModel() + viewModel.onLabelTextChanged("New Label") + viewModel.save() + + assertTrue(viewModel.uiState.value.showAboutOverrideSheet) + } + + @Test + fun `save does not show about override warning when label is cleared`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Original") + coEvery { memberLabelRepo.getRecipient(any()) } returns Recipient(about = "Some about text", aboutEmoji = null) + coEvery { memberLabelRepo.setLabel(any(), any()) } returns NetworkResult.Success(Unit) + every { memberLabelRepo.hasDismissedMemberLabelAboutOverrideWarning() } returns false + + val viewModel = createViewModel() + viewModel.clearLabel() + viewModel.save() + + assertFalse(viewModel.uiState.value.showAboutOverrideSheet) + assertEquals(SaveState.Success, viewModel.uiState.value.saveState) + } + + @Test + fun `save does not show about override warning when recipient has no about text or emoji`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getRecipient(any()) } returns Recipient(about = null, aboutEmoji = null) + coEvery { memberLabelRepo.setLabel(any(), any()) } returns NetworkResult.Success(Unit) + + val viewModel = createViewModel() + viewModel.onLabelTextChanged("New Label") + viewModel.save() + + assertFalse(viewModel.uiState.value.showAboutOverrideSheet) + assertEquals(SaveState.Success, viewModel.uiState.value.saveState) + } + + @Test + fun `save does not show about override warning if the warning has been dismissed`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getRecipient(any()) } returns Recipient(about = "Some about text") + coEvery { memberLabelRepo.setLabel(any(), any()) } returns NetworkResult.Success(Unit) + every { memberLabelRepo.hasDismissedMemberLabelAboutOverrideWarning() } returns true + + val viewModel = createViewModel() + viewModel.onLabelTextChanged("New Label") + viewModel.save() + + assertFalse(viewModel.uiState.value.showAboutOverrideSheet) + assertEquals(SaveState.Success, viewModel.uiState.value.saveState) + } + + @Test + fun `onAboutOverrideSheetShown resets showAboutOverrideSheet`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + coEvery { memberLabelRepo.getRecipient(any()) } returns Recipient(about = "Some about text") + coEvery { memberLabelRepo.setLabel(any(), any()) } returns NetworkResult.Success(Unit) + every { memberLabelRepo.hasDismissedMemberLabelAboutOverrideWarning() } returns false + + val viewModel = createViewModel() + viewModel.onLabelTextChanged("New Label") + viewModel.save() + + assertTrue(viewModel.uiState.value.showAboutOverrideSheet) + viewModel.onAboutOverrideSheetShown() + assertFalse(viewModel.uiState.value.showAboutOverrideSheet) + } + + @Test + fun `onAboutOverrideSheetDismissed sets saveState to Success`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + + val viewModel = createViewModel() + viewModel.onAboutOverrideSheetDismissed(dontShowAgain = false) + + assertEquals(SaveState.Success, viewModel.uiState.value.saveState) + } + + @Test + fun `onAboutOverrideSheetDismissed marks about override warning as dismissed when dontShowAgain = true`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + + val viewModel = createViewModel() + viewModel.onAboutOverrideSheetDismissed(dontShowAgain = true) + + verify(exactly = 1) { memberLabelRepo.markMemberLabelAboutOverrideWarningDismissed() } + } + + @Test + fun `onAboutOverrideSheetDismissed does not mark about override warning as dismissed when dontShowAgain = false`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + + val viewModel = createViewModel() + viewModel.onAboutOverrideSheetDismissed(dontShowAgain = false) + + verify(exactly = 0) { memberLabelRepo.markMemberLabelAboutOverrideWarningDismissed() } + } }