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);
}
}

View File

@@ -9495,5 +9495,10 @@
<!-- Button to edit an existing member label. -->
<string name="MemberLabelsEducation__edit_label">Edit your label</string>
<!-- Title for screen shown to let the user know that displaying their member label will take priority over their about text. -->
<string name="MemberLabelsAboutOverride__title">Member label display</string>
<!-- Body for screen shown to let the user know that displaying their member label will take priority over their about text. -->
<string name="MemberLabelsAboutOverride__body">In this group, your Member Label will be displayed beside your photo in place of your About.</string>
<!-- EOF -->
</resources>

View File

@@ -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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Label")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Label")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Label")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns null
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns null
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Label")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Label")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns null
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns null
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns null
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns null
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns null
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns null
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns null
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
coEvery { memberLabelRepo.getLabel(groupId, any<Recipient>()) } 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<Recipient>()) } 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<Recipient>()) } 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<Recipient>()) } 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<Recipient>()) } 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<Recipient>()) } 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<Recipient>()) } 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<Recipient>()) } 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<Recipient>()) } 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<Recipient>()) } returns null
val viewModel = createViewModel()
viewModel.onAboutOverrideSheetDismissed(dontShowAgain = false)
verify(exactly = 0) { memberLabelRepo.markMemberLabelAboutOverrideWarningDismissed() }
}
}