From d5b2f4fdd318825c018e04e1eeeabde50bb767ef Mon Sep 17 00:00:00 2001 From: jeffrey-signal Date: Fri, 6 Feb 2026 12:18:15 -0500 Subject: [PATCH] Display member label on recipient details sheet. --- .../groups/memberlabel/MemberLabelPill.kt | 97 +++++++++++++++++++ .../groups/memberlabel/MemberLabelPillView.kt | 49 ++++++++++ .../memberlabel/MemberLabelRepository.kt | 33 +++++-- .../memberlabel/MemberLabelViewModel.kt | 14 +-- .../RecipientBottomSheetDialogFragment.kt | 48 ++++++--- .../ui/bottomsheet/RecipientDetailsState.kt | 14 +++ .../bottomsheet/RecipientDialogViewModel.java | 64 +++++++++--- .../securesms/util/RemoteConfig.kt | 11 +++ .../res/layout/recipient_bottom_sheet.xml | 27 ++++-- .../memberlabel/MemberLabelViewModelTest.kt | 83 ++++++++-------- 10 files changed, 352 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPill.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPillView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDetailsState.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPill.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPill.kt new file mode 100644 index 0000000000..0ceba7a879 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPill.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.groups.memberlabel + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews + +/** + * Displays member label text with an optional emoji. + */ +@Composable +fun MemberLabelPill( + emoji: String?, + text: String, + tintColor: Color, + modifier: Modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), + textStyle: TextStyle = MaterialTheme.typography.bodyLarge +) { + val isDark = isSystemInDarkTheme() + val backgroundColor = tintColor.copy(alpha = if (isDark) 0.32f else 0.10f) + + val textColor = if (isDark) { + Color.White.copy(alpha = 0.25f).compositeOver(tintColor) + } else { + Color.Black.copy(alpha = 0.30f).compositeOver(tintColor) + } + + Row( + modifier = Modifier + .background( + color = backgroundColor, + shape = RoundedCornerShape(percent = 50) + ) + .then(modifier), + verticalAlignment = Alignment.CenterVertically + ) { + if (!emoji.isNullOrEmpty()) { + Text( + text = emoji, + style = textStyle, + modifier = Modifier.padding(end = 5.dp) + ) + } + + if (text.isNotEmpty()) { + Text( + text = text, + color = textColor, + style = textStyle, + maxLines = 1 + ) + } + } +} + +@DayNightPreviews +@Composable +private fun MemberLabelWithEmojiPreview() = Previews.Preview { + Box(modifier = Modifier.width(200.dp)) { + MemberLabelPill( + emoji = "🧠", + text = "Zero-Knowledge Know-It-All", + tintColor = Color(0xFF7A3DF5) + ) + } +} + +@DayNightPreviews +@Composable +private fun MemberLabelTextOnlyPreview() = Previews.Preview { + Box(modifier = Modifier.width(200.dp)) { + MemberLabelPill( + emoji = null, + text = "Zero-Knowledge Know-It-All", + tintColor = Color(0xFF7A3DF5) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPillView.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPillView.kt new file mode 100644 index 0000000000..5c7c8e2564 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPillView.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.groups.memberlabel + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.ColorInt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.AbstractComposeView + +/** + * @see MemberLabelPill + */ +class MemberLabelPillView : AbstractComposeView { + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + private var memberLabel: MemberLabel? by mutableStateOf(null) + private var tintColor: Color by mutableStateOf(Color.Unspecified) + + fun setLabel(label: MemberLabel, tintColor: Color) { + this.memberLabel = label + this.tintColor = tintColor + } + + fun setLabel(label: MemberLabel, @ColorInt tintColor: Int) { + this.memberLabel = label + this.tintColor = Color(tintColor) + } + + @Composable + override fun Content() { + memberLabel?.let { label -> + MemberLabelPill( + emoji = label.emoji, + text = label.text, + tintColor = tintColor + ) + } + } +} 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 5d9d09925c..c6f1ffd0c2 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 @@ -6,7 +6,9 @@ package org.thoughtcrime.securesms.groups.memberlabel import android.content.Context +import androidx.annotation.WorkerThread import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.signal.core.models.ServiceId import org.signal.core.util.orNull @@ -22,26 +24,45 @@ import org.thoughtcrime.securesms.util.RemoteConfig /** * Handles the retrieval and modification of group member labels. */ -class MemberLabelRepository( - private val groupId: GroupId.V2, +class MemberLabelRepository private constructor( private val context: Context = AppDependencies.application, private val groupsTable: GroupTable = SignalDatabase.groups ) { + companion object { + @JvmStatic + val instance: MemberLabelRepository by lazy { MemberLabelRepository() } + } + /** * Gets the member label for a specific recipient in the group. */ - suspend fun getLabel(recipientId: RecipientId): MemberLabel? = withContext(Dispatchers.IO) { - val recipient = Recipient.resolved(recipientId) + suspend fun getLabel(groupId: GroupId.V2, recipientId: RecipientId): MemberLabel? = withContext(Dispatchers.IO) { + getLabel(groupId, Recipient.resolved(recipientId)) + } + + /** + * Gets the member label for a specific recipient in the group (blocking version for Java compatibility). + */ + @WorkerThread + fun getLabelJava(groupId: GroupId.V2, recipient: Recipient): MemberLabel? = runBlocking { getLabel(groupId, recipient) } + + /** + * Gets the member label for a specific recipient in the group. + */ + suspend fun getLabel(groupId: GroupId.V2, recipient: Recipient): MemberLabel? = withContext(Dispatchers.IO) { + if (!RemoteConfig.receiveMemberLabels) { + return@withContext null + } + val aci = recipient.serviceId.orNull() as? ServiceId.ACI ?: return@withContext null val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext null - return@withContext groupRecord.requireV2GroupProperties().memberLabel(aci) } /** * Sets the group member label for the current user. */ - suspend fun setLabel(label: MemberLabel): Unit = withContext(Dispatchers.IO) { + suspend fun setLabel(groupId: GroupId.V2, label: MemberLabel): Unit = withContext(Dispatchers.IO) { if (!RemoteConfig.sendMemberLabels) { throw IllegalStateException("Set member label not allowed due to remote config.") } 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 ac0e39f349..901df2e25a 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 @@ -21,18 +21,11 @@ private const val MIN_LABEL_TEXT_LENGTH = 1 private const val MAX_LABEL_TEXT_LENGTH = 24 class MemberLabelViewModel( - private val memberLabelRepo: MemberLabelRepository, + private val memberLabelRepo: MemberLabelRepository = MemberLabelRepository.instance, + private val groupId: GroupId.V2, private val recipientId: RecipientId ) : ViewModel() { - constructor( - groupId: GroupId.V2, - recipientId: RecipientId - ) : this( - memberLabelRepo = MemberLabelRepository(groupId = groupId), - recipientId = recipientId - ) - private var originalLabelEmoji: String = "" private var originalLabelText: String = "" @@ -45,7 +38,7 @@ class MemberLabelViewModel( private fun loadExistingLabel() { viewModelScope.launch(SignalDispatchers.IO) { - val memberLabel = memberLabelRepo.getLabel(recipientId) + val memberLabel = memberLabelRepo.getLabel(groupId, recipientId) originalLabelEmoji = memberLabel?.emoji.orEmpty() originalLabelText = memberLabel?.text.orEmpty() @@ -103,6 +96,7 @@ class MemberLabelViewModel( val currentState = internalUiState.value memberLabelRepo.setLabel( + groupId = groupId, label = MemberLabel( emoji = currentState.labelEmoji.ifEmpty { null }, text = currentState.labelText diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.kt index 22e4b6f662..a23f0d3798 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientBottomSheetDialogFragment.kt @@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.B import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache import org.thoughtcrime.securesms.fonts.SignalSymbols import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelPillView import org.thoughtcrime.securesms.nicknames.NicknameActivity import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientExporter @@ -108,7 +109,8 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr val avatar: AvatarView = view.findViewById(R.id.rbs_recipient_avatar) val fullName: TextView = view.findViewById(R.id.rbs_full_name) - val about: TextView = view.findViewById(R.id.rbs_about) + val memberLabelView: MemberLabelPillView = view.findViewById(R.id.rbs_member_label) + val aboutView: TextView = view.findViewById(R.id.rbs_about) val nickname: TextView = view.findViewById(R.id.rbs_nickname_button) val blockButton: TextView = view.findViewById(R.id.rbs_block_button) val unblockButton: TextView = view.findViewById(R.id.rbs_unblock_button) @@ -148,6 +150,7 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr AvatarDownloadStateCache.forRecipient(recipient.id).collect { when (it) { AvatarDownloadStateCache.DownloadState.NONE -> {} + AvatarDownloadStateCache.DownloadState.IN_PROGRESS -> { if (inProgress) { return@collect @@ -159,12 +162,14 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr delay(LOADING_DELAY) progressBar.visible = AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS } + AvatarDownloadStateCache.DownloadState.FINISHED -> { AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.NONE) viewModel.refreshGroupId(groupId) inProgress = false progressBar.visible = false } + AvatarDownloadStateCache.DownloadState.FAILED -> { AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.NONE) avatar.displayGradientBlur(recipient) @@ -256,18 +261,6 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr fullName.text = name } - var aboutText = recipient.combinedAboutAndEmoji - if (recipient.isReleaseNotes) { - aboutText = getString(R.string.ReleaseNotes__signal_release_notes_and_news) - } - - if (!aboutText.isNullOrEmpty()) { - about.text = aboutText - about.visible = true - } else { - about.visible = false - } - noteToSelfDescription.visible = recipient.isSelf if (RecipientUtil.isBlockable(recipient)) { @@ -339,6 +332,10 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr } } + viewModel.recipientDetails.observe(viewLifecycleOwner) { state -> + updateRecipientDetails(state, memberLabelView, aboutView) + } + viewModel.canAddToAGroup.observe(getViewLifecycleOwner()) { canAdd: Boolean -> addToGroupButton.setText(if (groupId == null) R.string.RecipientBottomSheet_add_to_a_group else R.string.RecipientBottomSheet_add_to_another_group) addToGroupButton.visible = canAdd @@ -449,6 +446,31 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr animator.start() } + private fun updateRecipientDetails( + state: RecipientDetailsState, + memberLabelView: MemberLabelPillView, + aboutView: TextView + ) { + when { + state.memberLabel != null -> { + memberLabelView.setLabel(state.memberLabel.label, state.memberLabel.tintColor) + memberLabelView.visible = true + aboutView.visible = false + } + + !state.aboutText.isNullOrBlank() -> { + aboutView.text = state.aboutText + aboutView.visible = true + memberLabelView.visible = false + } + + else -> { + memberLabelView.visible = false + aboutView.visible = false + } + } + } + interface Callback { fun onRecipientBottomSheetDismissed() fun onMessageClicked() diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDetailsState.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDetailsState.kt new file mode 100644 index 0000000000..9d4c06f3ce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDetailsState.kt @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.recipients.ui.bottomsheet + +import androidx.annotation.ColorInt +import org.thoughtcrime.securesms.groups.memberlabel.MemberLabel + +data class RecipientDetailsState( + val memberLabel: StyledMemberLabel?, + val aboutText: String? +) + +data class StyledMemberLabel( + val label: MemberLabel, + @param:ColorInt val tintColor: Int +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java index 4c7c123400..9ed2c7545a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java @@ -21,18 +21,23 @@ import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.BlockUnblockDialog; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity; +import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.model.IdentityRecord; import org.thoughtcrime.securesms.database.model.StoryViewState; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.memberlabel.MemberLabel; +import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelRepository; import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; import org.thoughtcrime.securesms.groups.ui.GroupErrors; import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity; import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; @@ -44,6 +49,7 @@ import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import org.thoughtcrime.securesms.verify.VerifyIdentityActivity; import java.util.Objects; +import java.util.Optional; import kotlin.Pair; @@ -52,16 +58,17 @@ import io.reactivex.rxjava3.disposables.Disposable; final class RecipientDialogViewModel extends ViewModel { - private final Context context; - private final RecipientDialogRepository recipientDialogRepository; - private final LiveData recipient; - private final MutableLiveData identity; - private final LiveData adminActionStatus; - private final LiveData canAddToAGroup; - private final MutableLiveData adminActionBusy; - private final MutableLiveData storyViewState; - private final CompositeDisposable disposables; - private final boolean isDeprecatedOrUnregistered; + private final Context context; + private final RecipientDialogRepository recipientDialogRepository; + private final LiveData recipient; + private final MutableLiveData identity; + private final LiveData adminActionStatus; + private final LiveData canAddToAGroup; + private final MutableLiveData adminActionBusy; + private final MutableLiveData storyViewState; + private final MutableLiveData recipientDetailsState; + private final CompositeDisposable disposables; + private final boolean isDeprecatedOrUnregistered; private RecipientDialogViewModel(@NonNull Context context, @NonNull RecipientDialogRepository recipientDialogRepository) { @@ -70,12 +77,14 @@ final class RecipientDialogViewModel extends ViewModel { this.identity = new MutableLiveData<>(); this.adminActionBusy = new MutableLiveData<>(false); this.storyViewState = new MutableLiveData<>(); + this.recipientDetailsState = new MutableLiveData<>(); this.disposables = new CompositeDisposable(); this.isDeprecatedOrUnregistered = SignalStore.misc().isClientDeprecated() || TextSecurePreferences.isUnauthorizedReceived(context); boolean recipientIsSelf = recipientDialogRepository.getRecipientId().equals(Recipient.self().getId()); - recipient = Recipient.live(recipientDialogRepository.getRecipientId()).getLiveData(); + final LiveRecipient liveRecipient = Recipient.live(recipientDialogRepository.getRecipientId()); + recipient = liveRecipient.getLiveData(); if (recipientDialogRepository.getGroupId() != null && recipientDialogRepository.getGroupId().isV2() && !recipientIsSelf) { LiveGroup source = new LiveGroup(recipientDialogRepository.getGroupId()); @@ -114,6 +123,35 @@ final class RecipientDialogViewModel extends ViewModel { .subscribe(storyViewState::postValue); disposables.add(storyViewStateDisposable); + + Disposable recipientDisposable = liveRecipient.observable().subscribe(this::updateRecipientDetailsState); + disposables.add(recipientDisposable); + } + + private void updateRecipientDetailsState(@NonNull Recipient recipient) { + GroupId groupId = recipientDialogRepository.getGroupId(); + String aboutText = recipient.isReleaseNotes() ? context.getString(R.string.ReleaseNotes__signal_release_notes_and_news) : recipient.getCombinedAboutAndEmoji(); + + if (groupId != null && groupId.isV2() && recipient.isIndividual() && !recipient.isSelf()) { + SignalExecutors.BOUNDED.execute(() -> { + GroupId.V2 v2GroupId = (GroupId.V2) groupId; + MemberLabel label = MemberLabelRepository.getInstance().getLabelJava(v2GroupId, recipient); + StyledMemberLabel styledLabel = null; + + if (label != null) { + Colorizer colorizer = new Colorizer(); + Optional groupRecord = SignalDatabase.groups().getGroup(v2GroupId); + if (groupRecord.isPresent()) { + colorizer.onGroupMembershipChanged(groupRecord.get().requireV2GroupProperties().getMemberServiceIds()); + } + styledLabel = new StyledMemberLabel(label, colorizer.getIncomingGroupSenderColor(context, recipient)); + } + + recipientDetailsState.postValue(new RecipientDetailsState(styledLabel, aboutText)); + }); + } else { + recipientDetailsState.setValue(new RecipientDetailsState(null, aboutText)); + } } @Override protected void onCleared() { @@ -149,6 +187,10 @@ final class RecipientDialogViewModel extends ViewModel { return adminActionBusy; } + LiveData getRecipientDetails() { + return recipientDetailsState; + } + void onNoteToSelfClicked(@NonNull Activity activity) { if (storyViewState.getValue() == null || storyViewState.getValue() == StoryViewState.NONE) { onMessageClicked(activity); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index 947b59c8b3..0ea7a370d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -305,11 +305,13 @@ object RemoteConfig { val newKey = key.removePrefix("android.libsignal.") when (value) { is String -> newKey to value + // The server is currently synthesizing "true" / "false" values // for RemoteConfigs that are otherwise empty string values. // Libsignal expects that disabled values are simply absent from the // map, so we map true to "true" and otherwise omit disabled values. is Boolean -> if (value) newKey to "true" else null + else -> { val type = value?.let { value::class.simpleName } Log.w(TAG, "[libsignal] Unexpected type for $newKey! Was a $type") @@ -1237,6 +1239,15 @@ object RemoteConfig { hotSwappable = true ) + /** + * Whether to receive and display group member labels. + */ + val receiveMemberLabels: Boolean by remoteBoolean( + key = "android.receiveMemberLabels", + defaultValue = false, + hotSwappable = true + ) + /** * Whether to enable modifying group member labels. */ diff --git a/app/src/main/res/layout/recipient_bottom_sheet.xml b/app/src/main/res/layout/recipient_bottom_sheet.xml index 269622483c..7f8844cc66 100644 --- a/app/src/main/res/layout/recipient_bottom_sheet.xml +++ b/app/src/main/res/layout/recipient_bottom_sheet.xml @@ -18,10 +18,10 @@ android:layout_gravity="center_horizontal" android:layout_marginTop="10dp" android:importantForAccessibility="no" - app:srcCompat="@drawable/bottom_sheet_handle" - app:layout_constraintTop_toTopOf="parent" + app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent"/> + app:layout_constraintTop_toTopOf="parent" + app:srcCompat="@drawable/bottom_sheet_handle" /> + + (relaxUnitFun = true) + private val groupId = mockk() private val recipientId = RecipientId.from(1L) @Test fun `isSaveEnabled returns true when label text is different from the original value`() { - coEvery { memberLabelRepo.getLabel(any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.onLabelTextChanged("Modified") assertTrue(viewModel.uiState.value.isSaveEnabled) @@ -40,9 +42,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns false when label text is the same as the original value`() { - coEvery { memberLabelRepo.getLabel(any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.onLabelTextChanged("Original") assertFalse(viewModel.uiState.value.isSaveEnabled) @@ -50,9 +52,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(any()) } returns MemberLabel(emoji = null, text = "Label") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Label") - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.onLabelEmojiChanged("🎉") assertTrue(viewModel.uiState.value.isSaveEnabled) @@ -60,18 +62,18 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns false when the label and emoji are not changed`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(any()) } returns MemberLabel(emoji = "🎉", text = "Label") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Label") - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) 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(any()) } returns MemberLabel(emoji = "🎉", text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.onLabelEmojiChanged("🫢") viewModel.onLabelTextChanged("Modified") @@ -84,9 +86,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns false when label is too short`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(any()) } returns MemberLabel(emoji = null, text = "Label") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Label") - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.onLabelTextChanged("") viewModel.onLabelEmojiChanged("🎉") @@ -95,9 +97,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns true when clearLabel is called with existing label and emoji`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(any()) } returns MemberLabel(emoji = "🎉", text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.clearLabel() assertTrue(viewModel.uiState.value.isSaveEnabled) @@ -105,9 +107,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns true when clearLabel is called with existing label without emoji`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.clearLabel() assertTrue(viewModel.uiState.value.isSaveEnabled) @@ -115,9 +117,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns false when clearLabel is called with no existing label`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(any()) } returns null + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.clearLabel() assertFalse(viewModel.uiState.value.isSaveEnabled) @@ -125,9 +127,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns true when both emoji and label are modified`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(any()) } returns MemberLabel(emoji = "🎉", text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.onLabelTextChanged("New Label") viewModel.onLabelEmojiChanged("🚀") @@ -136,9 +138,9 @@ class MemberLabelViewModelTest { @Test fun `isSaveEnabled returns false when only emoji is changed without an existing label`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(any()) } returns null + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.onLabelEmojiChanged("🎉") assertFalse(viewModel.uiState.value.isSaveEnabled) @@ -146,76 +148,77 @@ class MemberLabelViewModelTest { @Test fun `save does not call setLabel when isSaveEnabled is false`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(any()) } returns MemberLabel(emoji = null, text = "Label") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Label") - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.save() - coVerify(exactly = 0) { memberLabelRepo.setLabel(any()) } + coVerify(exactly = 0) { memberLabelRepo.setLabel(groupId, any()) } } @Test fun `save does not call setLabel when label is less than 1 character`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(any()) } returns MemberLabel(emoji = null, text = "Label") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Label") - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.onLabelTextChanged("") viewModel.onLabelEmojiChanged("🎉") viewModel.save() - coVerify(exactly = 0) { memberLabelRepo.setLabel(any()) } + coVerify(exactly = 0) { memberLabelRepo.setLabel(groupId, any()) } } @Test fun `save calls setLabel with truncated label when label exceeds max length`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(any()) } returns null + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.onLabelTextChanged("A".repeat(30)) viewModel.save() coVerify(exactly = 1) { memberLabelRepo.setLabel( - match { it.text.length == 24 } + groupId = groupId, + label = match { it.text.length == 24 } ) } } @Test fun `save does not call setLabel when emoji is set with no label`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(any()) } returns null + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.onLabelEmojiChanged("🎉") viewModel.save() - coVerify(exactly = 0) { memberLabelRepo.setLabel(any()) } + coVerify(exactly = 0) { memberLabelRepo.setLabel(groupId, any()) } } @Test fun `save calls setLabel when label change is valid`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(any()) } returns MemberLabel(emoji = null, text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.onLabelTextChanged("New Label") viewModel.onLabelEmojiChanged("🎉") viewModel.save() coVerify(exactly = 1) { - memberLabelRepo.setLabel(MemberLabel(text = "New Label", emoji = "🎉")) + memberLabelRepo.setLabel(groupId, MemberLabel(text = "New Label", emoji = "🎉")) } } @Test fun `save calls setLabel with cleared values when clearLabel is called`() = runTest(testDispatcher) { - coEvery { memberLabelRepo.getLabel(any()) } returns MemberLabel(emoji = "🎉", text = "Original") + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = "🎉", text = "Original") - val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) viewModel.clearLabel() viewModel.save() coVerify(exactly = 1) { - memberLabelRepo.setLabel(MemberLabel(text = "", emoji = null)) + memberLabelRepo.setLabel(groupId, MemberLabel(text = "", emoji = null)) } } }