Display member label on recipient details sheet.

This commit is contained in:
jeffrey-signal
2026-02-06 12:18:15 -05:00
committed by Greyson Parrelli
parent fae4ca91bd
commit d5b2f4fdd3
10 changed files with 352 additions and 88 deletions

View File

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

View File

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

View File

@@ -6,7 +6,9 @@
package org.thoughtcrime.securesms.groups.memberlabel package org.thoughtcrime.securesms.groups.memberlabel
import android.content.Context import android.content.Context
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.signal.core.models.ServiceId import org.signal.core.models.ServiceId
import org.signal.core.util.orNull 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. * Handles the retrieval and modification of group member labels.
*/ */
class MemberLabelRepository( class MemberLabelRepository private constructor(
private val groupId: GroupId.V2,
private val context: Context = AppDependencies.application, private val context: Context = AppDependencies.application,
private val groupsTable: GroupTable = SignalDatabase.groups 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. * Gets the member label for a specific recipient in the group.
*/ */
suspend fun getLabel(recipientId: RecipientId): MemberLabel? = withContext(Dispatchers.IO) { suspend fun getLabel(groupId: GroupId.V2, recipientId: RecipientId): MemberLabel? = withContext(Dispatchers.IO) {
val recipient = Recipient.resolved(recipientId) 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 aci = recipient.serviceId.orNull() as? ServiceId.ACI ?: return@withContext null
val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext null val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext null
return@withContext groupRecord.requireV2GroupProperties().memberLabel(aci) return@withContext groupRecord.requireV2GroupProperties().memberLabel(aci)
} }
/** /**
* Sets the group member label for the current user. * 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) { if (!RemoteConfig.sendMemberLabels) {
throw IllegalStateException("Set member label not allowed due to remote config.") throw IllegalStateException("Set member label not allowed due to remote config.")
} }

View File

@@ -21,18 +21,11 @@ private const val MIN_LABEL_TEXT_LENGTH = 1
private const val MAX_LABEL_TEXT_LENGTH = 24 private const val MAX_LABEL_TEXT_LENGTH = 24
class MemberLabelViewModel( class MemberLabelViewModel(
private val memberLabelRepo: MemberLabelRepository, private val memberLabelRepo: MemberLabelRepository = MemberLabelRepository.instance,
private val groupId: GroupId.V2,
private val recipientId: RecipientId private val recipientId: RecipientId
) : ViewModel() { ) : ViewModel() {
constructor(
groupId: GroupId.V2,
recipientId: RecipientId
) : this(
memberLabelRepo = MemberLabelRepository(groupId = groupId),
recipientId = recipientId
)
private var originalLabelEmoji: String = "" private var originalLabelEmoji: String = ""
private var originalLabelText: String = "" private var originalLabelText: String = ""
@@ -45,7 +38,7 @@ class MemberLabelViewModel(
private fun loadExistingLabel() { private fun loadExistingLabel() {
viewModelScope.launch(SignalDispatchers.IO) { viewModelScope.launch(SignalDispatchers.IO) {
val memberLabel = memberLabelRepo.getLabel(recipientId) val memberLabel = memberLabelRepo.getLabel(groupId, recipientId)
originalLabelEmoji = memberLabel?.emoji.orEmpty() originalLabelEmoji = memberLabel?.emoji.orEmpty()
originalLabelText = memberLabel?.text.orEmpty() originalLabelText = memberLabel?.text.orEmpty()
@@ -103,6 +96,7 @@ class MemberLabelViewModel(
val currentState = internalUiState.value val currentState = internalUiState.value
memberLabelRepo.setLabel( memberLabelRepo.setLabel(
groupId = groupId,
label = MemberLabel( label = MemberLabel(
emoji = currentState.labelEmoji.ifEmpty { null }, emoji = currentState.labelEmoji.ifEmpty { null },
text = currentState.labelText text = currentState.labelText

View File

@@ -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.conversation.v2.data.AvatarDownloadStateCache
import org.thoughtcrime.securesms.fonts.SignalSymbols import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelPillView
import org.thoughtcrime.securesms.nicknames.NicknameActivity import org.thoughtcrime.securesms.nicknames.NicknameActivity
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientExporter import org.thoughtcrime.securesms.recipients.RecipientExporter
@@ -108,7 +109,8 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
val avatar: AvatarView = view.findViewById(R.id.rbs_recipient_avatar) val avatar: AvatarView = view.findViewById(R.id.rbs_recipient_avatar)
val fullName: TextView = view.findViewById(R.id.rbs_full_name) 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 nickname: TextView = view.findViewById(R.id.rbs_nickname_button)
val blockButton: TextView = view.findViewById(R.id.rbs_block_button) val blockButton: TextView = view.findViewById(R.id.rbs_block_button)
val unblockButton: TextView = view.findViewById(R.id.rbs_unblock_button) val unblockButton: TextView = view.findViewById(R.id.rbs_unblock_button)
@@ -148,6 +150,7 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
AvatarDownloadStateCache.forRecipient(recipient.id).collect { AvatarDownloadStateCache.forRecipient(recipient.id).collect {
when (it) { when (it) {
AvatarDownloadStateCache.DownloadState.NONE -> {} AvatarDownloadStateCache.DownloadState.NONE -> {}
AvatarDownloadStateCache.DownloadState.IN_PROGRESS -> { AvatarDownloadStateCache.DownloadState.IN_PROGRESS -> {
if (inProgress) { if (inProgress) {
return@collect return@collect
@@ -159,12 +162,14 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
delay(LOADING_DELAY) delay(LOADING_DELAY)
progressBar.visible = AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS progressBar.visible = AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS
} }
AvatarDownloadStateCache.DownloadState.FINISHED -> { AvatarDownloadStateCache.DownloadState.FINISHED -> {
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.NONE) AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.NONE)
viewModel.refreshGroupId(groupId) viewModel.refreshGroupId(groupId)
inProgress = false inProgress = false
progressBar.visible = false progressBar.visible = false
} }
AvatarDownloadStateCache.DownloadState.FAILED -> { AvatarDownloadStateCache.DownloadState.FAILED -> {
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.NONE) AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.NONE)
avatar.displayGradientBlur(recipient) avatar.displayGradientBlur(recipient)
@@ -256,18 +261,6 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
fullName.text = name 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 noteToSelfDescription.visible = recipient.isSelf
if (RecipientUtil.isBlockable(recipient)) { 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 -> 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.setText(if (groupId == null) R.string.RecipientBottomSheet_add_to_a_group else R.string.RecipientBottomSheet_add_to_another_group)
addToGroupButton.visible = canAdd addToGroupButton.visible = canAdd
@@ -449,6 +446,31 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
animator.start() 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 { interface Callback {
fun onRecipientBottomSheetDismissed() fun onRecipientBottomSheetDismissed()
fun onMessageClicked() fun onMessageClicked()

View File

@@ -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
)

View File

@@ -21,18 +21,23 @@ import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.BlockUnblockDialog; import org.thoughtcrime.securesms.BlockUnblockDialog;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity; 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.GroupTable;
import org.thoughtcrime.securesms.database.SignalDatabase; 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.IdentityRecord;
import org.thoughtcrime.securesms.database.model.StoryViewState; import org.thoughtcrime.securesms.database.model.StoryViewState;
import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.LiveGroup; 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.GroupChangeFailureReason;
import org.thoughtcrime.securesms.groups.ui.GroupErrors; import org.thoughtcrime.securesms.groups.ui.GroupErrors;
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity; import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity;
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob; import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.recipients.RecipientUtil;
@@ -44,6 +49,7 @@ import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity; import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import kotlin.Pair; import kotlin.Pair;
@@ -52,16 +58,17 @@ import io.reactivex.rxjava3.disposables.Disposable;
final class RecipientDialogViewModel extends ViewModel { final class RecipientDialogViewModel extends ViewModel {
private final Context context; private final Context context;
private final RecipientDialogRepository recipientDialogRepository; private final RecipientDialogRepository recipientDialogRepository;
private final LiveData<Recipient> recipient; private final LiveData<Recipient> recipient;
private final MutableLiveData<IdentityRecord> identity; private final MutableLiveData<IdentityRecord> identity;
private final LiveData<AdminActionStatus> adminActionStatus; private final LiveData<AdminActionStatus> adminActionStatus;
private final LiveData<Boolean> canAddToAGroup; private final LiveData<Boolean> canAddToAGroup;
private final MutableLiveData<Boolean> adminActionBusy; private final MutableLiveData<Boolean> adminActionBusy;
private final MutableLiveData<StoryViewState> storyViewState; private final MutableLiveData<StoryViewState> storyViewState;
private final CompositeDisposable disposables; private final MutableLiveData<RecipientDetailsState> recipientDetailsState;
private final boolean isDeprecatedOrUnregistered; private final CompositeDisposable disposables;
private final boolean isDeprecatedOrUnregistered;
private RecipientDialogViewModel(@NonNull Context context, private RecipientDialogViewModel(@NonNull Context context,
@NonNull RecipientDialogRepository recipientDialogRepository) @NonNull RecipientDialogRepository recipientDialogRepository)
{ {
@@ -70,12 +77,14 @@ final class RecipientDialogViewModel extends ViewModel {
this.identity = new MutableLiveData<>(); this.identity = new MutableLiveData<>();
this.adminActionBusy = new MutableLiveData<>(false); this.adminActionBusy = new MutableLiveData<>(false);
this.storyViewState = new MutableLiveData<>(); this.storyViewState = new MutableLiveData<>();
this.recipientDetailsState = new MutableLiveData<>();
this.disposables = new CompositeDisposable(); this.disposables = new CompositeDisposable();
this.isDeprecatedOrUnregistered = SignalStore.misc().isClientDeprecated() || TextSecurePreferences.isUnauthorizedReceived(context); this.isDeprecatedOrUnregistered = SignalStore.misc().isClientDeprecated() || TextSecurePreferences.isUnauthorizedReceived(context);
boolean recipientIsSelf = recipientDialogRepository.getRecipientId().equals(Recipient.self().getId()); 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) { if (recipientDialogRepository.getGroupId() != null && recipientDialogRepository.getGroupId().isV2() && !recipientIsSelf) {
LiveGroup source = new LiveGroup(recipientDialogRepository.getGroupId()); LiveGroup source = new LiveGroup(recipientDialogRepository.getGroupId());
@@ -114,6 +123,35 @@ final class RecipientDialogViewModel extends ViewModel {
.subscribe(storyViewState::postValue); .subscribe(storyViewState::postValue);
disposables.add(storyViewStateDisposable); 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> 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() { @Override protected void onCleared() {
@@ -149,6 +187,10 @@ final class RecipientDialogViewModel extends ViewModel {
return adminActionBusy; return adminActionBusy;
} }
LiveData<RecipientDetailsState> getRecipientDetails() {
return recipientDetailsState;
}
void onNoteToSelfClicked(@NonNull Activity activity) { void onNoteToSelfClicked(@NonNull Activity activity) {
if (storyViewState.getValue() == null || storyViewState.getValue() == StoryViewState.NONE) { if (storyViewState.getValue() == null || storyViewState.getValue() == StoryViewState.NONE) {
onMessageClicked(activity); onMessageClicked(activity);

View File

@@ -305,11 +305,13 @@ object RemoteConfig {
val newKey = key.removePrefix("android.libsignal.") val newKey = key.removePrefix("android.libsignal.")
when (value) { when (value) {
is String -> newKey to value is String -> newKey to value
// The server is currently synthesizing "true" / "false" values // The server is currently synthesizing "true" / "false" values
// for RemoteConfigs that are otherwise empty string values. // for RemoteConfigs that are otherwise empty string values.
// Libsignal expects that disabled values are simply absent from the // Libsignal expects that disabled values are simply absent from the
// map, so we map true to "true" and otherwise omit disabled values. // map, so we map true to "true" and otherwise omit disabled values.
is Boolean -> if (value) newKey to "true" else null is Boolean -> if (value) newKey to "true" else null
else -> { else -> {
val type = value?.let { value::class.simpleName } val type = value?.let { value::class.simpleName }
Log.w(TAG, "[libsignal] Unexpected type for $newKey! Was a $type") Log.w(TAG, "[libsignal] Unexpected type for $newKey! Was a $type")
@@ -1237,6 +1239,15 @@ object RemoteConfig {
hotSwappable = true 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. * Whether to enable modifying group member labels.
*/ */

View File

@@ -18,10 +18,10 @@
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:layout_marginTop="10dp" android:layout_marginTop="10dp"
android:importantForAccessibility="no" android:importantForAccessibility="no"
app:srcCompat="@drawable/bottom_sheet_handle" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/> app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/bottom_sheet_handle" />
<org.thoughtcrime.securesms.avatar.view.AvatarView <org.thoughtcrime.securesms.avatar.view.AvatarView
android:id="@+id/rbs_recipient_avatar" android:id="@+id/rbs_recipient_avatar"
@@ -58,17 +58,17 @@
<ProgressBar <ProgressBar
android:id="@+id/rbs_progress_bar" android:id="@+id/rbs_progress_bar"
style="?android:attr/progressBarStyleLarge"
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="48dp" android:layout_height="48dp"
android:layout_gravity="center" android:layout_gravity="center"
android:visibility="gone"
android:indeterminate="true" android:indeterminate="true"
style="?android:attr/progressBarStyleLarge"
android:indeterminateTint="@color/signal_colorOnSurfaceVariant" android:indeterminateTint="@color/signal_colorOnSurfaceVariant"
app:layout_constraintTop_toTopOf="@id/rbs_recipient_avatar" android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/rbs_recipient_avatar" app:layout_constraintBottom_toBottomOf="@id/rbs_recipient_avatar"
app:layout_constraintStart_toStartOf="@id/rbs_recipient_avatar"
app:layout_constraintEnd_toEndOf="@id/rbs_recipient_avatar" app:layout_constraintEnd_toEndOf="@id/rbs_recipient_avatar"
app:layout_constraintStart_toStartOf="@id/rbs_recipient_avatar"
app:layout_constraintTop_toTopOf="@id/rbs_recipient_avatar"
tools:visibility="visible" /> tools:visibility="visible" />
<LinearLayout <LinearLayout
@@ -115,6 +115,17 @@
app:layout_constraintTop_toBottomOf="@id/rbs_recipient_avatar" app:layout_constraintTop_toBottomOf="@id/rbs_recipient_avatar"
tools:text="Gwen Stacy" /> tools:text="Gwen Stacy" />
<org.thoughtcrime.securesms.groups.memberlabel.MemberLabelPillView
android:id="@+id/rbs_member_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/rbs_full_name"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView <org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/rbs_about" android:id="@+id/rbs_about"
style="@style/Signal.Text.BodyLarge" style="@style/Signal.Text.BodyLarge"
@@ -125,7 +136,7 @@
android:layout_marginEnd="@dimen/dsl_settings_gutter" android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:gravity="center" android:gravity="center"
android:textColor="@color/signal_text_secondary" android:textColor="@color/signal_text_secondary"
app:layout_constraintTop_toBottomOf="@id/rbs_full_name" app:layout_constraintTop_toBottomOf="@id/rbs_member_label"
tools:text="🕷🕷🕷 Hangin' on the web 🕷🕷🕷" /> tools:text="🕷🕷🕷 Hangin' on the web 🕷🕷🕷" />
<TextView <TextView

View File

@@ -15,6 +15,7 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
@@ -26,13 +27,14 @@ class MemberLabelViewModelTest {
val dispatcherRule = CoroutineDispatcherRule(testDispatcher) val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
private val memberLabelRepo = mockk<MemberLabelRepository>(relaxUnitFun = true) private val memberLabelRepo = mockk<MemberLabelRepository>(relaxUnitFun = true)
private val groupId = mockk<GroupId.V2>()
private val recipientId = RecipientId.from(1L) private val recipientId = RecipientId.from(1L)
@Test @Test
fun `isSaveEnabled returns true when label text is different from the original value`() { fun `isSaveEnabled returns true when label text is different from the original value`() {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original") coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelTextChanged("Modified") viewModel.onLabelTextChanged("Modified")
assertTrue(viewModel.uiState.value.isSaveEnabled) assertTrue(viewModel.uiState.value.isSaveEnabled)
@@ -40,9 +42,9 @@ class MemberLabelViewModelTest {
@Test @Test
fun `isSaveEnabled returns false when label text is the same as the original value`() { fun `isSaveEnabled returns false when label text is the same as the original value`() {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original") coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelTextChanged("Original") viewModel.onLabelTextChanged("Original")
assertFalse(viewModel.uiState.value.isSaveEnabled) assertFalse(viewModel.uiState.value.isSaveEnabled)
@@ -50,9 +52,9 @@ class MemberLabelViewModelTest {
@Test @Test
fun `isSaveEnabled returns true when label text is valid and the emoji is different from the original value`() = runTest(testDispatcher) { fun `isSaveEnabled returns true when label text is valid and the emoji is different from the original value`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Label") coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Label")
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelEmojiChanged("🎉") viewModel.onLabelEmojiChanged("🎉")
assertTrue(viewModel.uiState.value.isSaveEnabled) assertTrue(viewModel.uiState.value.isSaveEnabled)
@@ -60,18 +62,18 @@ class MemberLabelViewModelTest {
@Test @Test
fun `isSaveEnabled returns false when the label and emoji are not changed`() = runTest(testDispatcher) { fun `isSaveEnabled returns false when the label and emoji are not changed`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Label") coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Label")
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
assertFalse(viewModel.uiState.value.isSaveEnabled) assertFalse(viewModel.uiState.value.isSaveEnabled)
} }
@Test @Test
fun `isSaveEnabled returns false when the label and emoji are changed to the original value`() = runTest(testDispatcher) { fun `isSaveEnabled returns false when the label and emoji are changed to the original value`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Original") coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Original")
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelEmojiChanged("🫢") viewModel.onLabelEmojiChanged("🫢")
viewModel.onLabelTextChanged("Modified") viewModel.onLabelTextChanged("Modified")
@@ -84,9 +86,9 @@ class MemberLabelViewModelTest {
@Test @Test
fun `isSaveEnabled returns false when label is too short`() = runTest(testDispatcher) { fun `isSaveEnabled returns false when label is too short`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Label") coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Label")
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelTextChanged("") viewModel.onLabelTextChanged("")
viewModel.onLabelEmojiChanged("🎉") viewModel.onLabelEmojiChanged("🎉")
@@ -95,9 +97,9 @@ class MemberLabelViewModelTest {
@Test @Test
fun `isSaveEnabled returns true when clearLabel is called with existing label and emoji`() = runTest(testDispatcher) { fun `isSaveEnabled returns true when clearLabel is called with existing label and emoji`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Original") coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Original")
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.clearLabel() viewModel.clearLabel()
assertTrue(viewModel.uiState.value.isSaveEnabled) assertTrue(viewModel.uiState.value.isSaveEnabled)
@@ -105,9 +107,9 @@ class MemberLabelViewModelTest {
@Test @Test
fun `isSaveEnabled returns true when clearLabel is called with existing label without emoji`() = runTest(testDispatcher) { fun `isSaveEnabled returns true when clearLabel is called with existing label without emoji`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original") coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.clearLabel() viewModel.clearLabel()
assertTrue(viewModel.uiState.value.isSaveEnabled) assertTrue(viewModel.uiState.value.isSaveEnabled)
@@ -115,9 +117,9 @@ class MemberLabelViewModelTest {
@Test @Test
fun `isSaveEnabled returns false when clearLabel is called with no existing label`() = runTest(testDispatcher) { fun `isSaveEnabled returns false when clearLabel is called with no existing label`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns null coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns null
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.clearLabel() viewModel.clearLabel()
assertFalse(viewModel.uiState.value.isSaveEnabled) assertFalse(viewModel.uiState.value.isSaveEnabled)
@@ -125,9 +127,9 @@ class MemberLabelViewModelTest {
@Test @Test
fun `isSaveEnabled returns true when both emoji and label are modified`() = runTest(testDispatcher) { fun `isSaveEnabled returns true when both emoji and label are modified`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Original") coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Original")
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelTextChanged("New Label") viewModel.onLabelTextChanged("New Label")
viewModel.onLabelEmojiChanged("🚀") viewModel.onLabelEmojiChanged("🚀")
@@ -136,9 +138,9 @@ class MemberLabelViewModelTest {
@Test @Test
fun `isSaveEnabled returns false when only emoji is changed without an existing label`() = runTest(testDispatcher) { fun `isSaveEnabled returns false when only emoji is changed without an existing label`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns null coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns null
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelEmojiChanged("🎉") viewModel.onLabelEmojiChanged("🎉")
assertFalse(viewModel.uiState.value.isSaveEnabled) assertFalse(viewModel.uiState.value.isSaveEnabled)
@@ -146,76 +148,77 @@ class MemberLabelViewModelTest {
@Test @Test
fun `save does not call setLabel when isSaveEnabled is false`() = runTest(testDispatcher) { fun `save does not call setLabel when isSaveEnabled is false`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Label") coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Label")
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.save() viewModel.save()
coVerify(exactly = 0) { memberLabelRepo.setLabel(any()) } coVerify(exactly = 0) { memberLabelRepo.setLabel(groupId, any()) }
} }
@Test @Test
fun `save does not call setLabel when label is less than 1 character`() = runTest(testDispatcher) { fun `save does not call setLabel when label is less than 1 character`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Label") coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Label")
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelTextChanged("") viewModel.onLabelTextChanged("")
viewModel.onLabelEmojiChanged("🎉") viewModel.onLabelEmojiChanged("🎉")
viewModel.save() viewModel.save()
coVerify(exactly = 0) { memberLabelRepo.setLabel(any()) } coVerify(exactly = 0) { memberLabelRepo.setLabel(groupId, any()) }
} }
@Test @Test
fun `save calls setLabel with truncated label when label exceeds max length`() = runTest(testDispatcher) { fun `save calls setLabel with truncated label when label exceeds max length`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns null coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns null
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelTextChanged("A".repeat(30)) viewModel.onLabelTextChanged("A".repeat(30))
viewModel.save() viewModel.save()
coVerify(exactly = 1) { coVerify(exactly = 1) {
memberLabelRepo.setLabel( memberLabelRepo.setLabel(
match { it.text.length == 24 } groupId = groupId,
label = match { it.text.length == 24 }
) )
} }
} }
@Test @Test
fun `save does not call setLabel when emoji is set with no label`() = runTest(testDispatcher) { fun `save does not call setLabel when emoji is set with no label`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns null coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns null
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelEmojiChanged("🎉") viewModel.onLabelEmojiChanged("🎉")
viewModel.save() viewModel.save()
coVerify(exactly = 0) { memberLabelRepo.setLabel(any()) } coVerify(exactly = 0) { memberLabelRepo.setLabel(groupId, any()) }
} }
@Test @Test
fun `save calls setLabel when label change is valid`() = runTest(testDispatcher) { fun `save calls setLabel when label change is valid`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original") coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelTextChanged("New Label") viewModel.onLabelTextChanged("New Label")
viewModel.onLabelEmojiChanged("🎉") viewModel.onLabelEmojiChanged("🎉")
viewModel.save() viewModel.save()
coVerify(exactly = 1) { coVerify(exactly = 1) {
memberLabelRepo.setLabel(MemberLabel(text = "New Label", emoji = "🎉")) memberLabelRepo.setLabel(groupId, MemberLabel(text = "New Label", emoji = "🎉"))
} }
} }
@Test @Test
fun `save calls setLabel with cleared values when clearLabel is called`() = runTest(testDispatcher) { fun `save calls setLabel with cleared values when clearLabel is called`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(any<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Original") coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = "🎉", text = "Original")
val viewModel = MemberLabelViewModel(memberLabelRepo, recipientId) val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.clearLabel() viewModel.clearLabel()
viewModel.save() viewModel.save()
coVerify(exactly = 1) { coVerify(exactly = 1) {
memberLabelRepo.setLabel(MemberLabel(text = "", emoji = null)) memberLabelRepo.setLabel(groupId, MemberLabel(text = "", emoji = null))
} }
} }
} }