Show other group members with labels.

This commit is contained in:
jeffrey-signal
2026-03-09 11:55:41 -04:00
parent ac9405e874
commit 7ff051a638
8 changed files with 190 additions and 17 deletions

View File

@@ -9,7 +9,6 @@ import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import com.annimon.stream.ComparatorCompat;
import com.annimon.stream.Stream;
import org.signal.core.models.ServiceId;
@@ -23,6 +22,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.groups.ui.GroupMemberOrder;
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
@@ -30,7 +30,6 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.text.Collator;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
@@ -38,15 +37,12 @@ import java.util.Set;
public final class LiveGroup {
private static final Collator collator = Collator.getInstance();
private static final Comparator<GroupMemberEntry.FullMember> LOCAL_FIRST = (m1, m2) -> Boolean.compare(m2.getMember().isSelf(), m1.getMember().isSelf());
private static final Comparator<GroupMemberEntry.FullMember> ADMIN_FIRST = (m1, m2) -> Boolean.compare(m2.isAdmin(), m1.isAdmin());
private static final Comparator<GroupMemberEntry.FullMember> HAS_DISPLAY_NAME = (m1, m2) -> Boolean.compare(m2.getMember().hasAUserSetDisplayName(AppDependencies.getApplication()), m1.getMember().hasAUserSetDisplayName(AppDependencies.getApplication()));
private static final Comparator<GroupMemberEntry.FullMember> ALPHABETICAL = (m1, m2) -> collator.compare(m1.getMember().getDisplayName(AppDependencies.getApplication()).toLowerCase(), m2.getMember().getDisplayName(AppDependencies.getApplication()).toLowerCase());
private static final Comparator<? super GroupMemberEntry.FullMember> MEMBER_ORDER = ComparatorCompat.chain(LOCAL_FIRST)
.thenComparing(ADMIN_FIRST)
.thenComparing(HAS_DISPLAY_NAME)
.thenComparing(ALPHABETICAL);
private static final Comparator<GroupMemberEntry.FullMember> MEMBER_ORDER = GroupMemberOrder.comparator(
it -> it.getMember().isSelf(),
it -> it.isAdmin(),
it -> it.getMember().hasAUserSetDisplayName(AppDependencies.getApplication()),
it -> it.getMember().getDisplayName(AppDependencies.getApplication())
);
private final GroupTable groupDatabase;
private final LiveData<Recipient> recipient;
@@ -65,8 +61,6 @@ public final class LiveGroup {
this.fullMembers = mapToFullMembers(this.groupRecord);
this.requestingMembers = mapToRequestingMembers(this.groupRecord);
collator.setStrength(Collator.PRIMARY);
if (groupId.isV2()) {
LiveData<GroupTable.V2GroupProperties> v2Properties = Transformations.map(this.groupRecord, GroupRecord::requireV2GroupProperties);
this.groupLink = Transformations.map(v2Properties, g -> {

View File

@@ -10,6 +10,8 @@ import org.signal.core.util.BidiUtil
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.StringUtil
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.recipients.Recipient
/**
* A member's custom label within a group.
@@ -67,3 +69,10 @@ data class StyledMemberLabel(
val label: MemberLabel,
@param:ColorInt val tintColor: Int
)
data class GroupMemberWithLabel(
val recipient: Recipient,
val isAdmin: Boolean,
val label: MemberLabel,
val nameColor: NameColor
)

View File

@@ -10,6 +10,7 @@ import android.view.View
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -20,6 +21,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -36,7 +38,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@@ -57,7 +62,9 @@ import org.signal.core.ui.compose.SignalIcons
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.requireParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.components.emoji.Emojifier
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveState
import org.thoughtcrime.securesms.profiles.ProfileName
@@ -201,6 +208,7 @@ private fun MemberLabelScreenUi(
.padding(horizontal = 24.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = 80.dp)
) {
Text(
text = stringResource(R.string.GroupMemberLabel__description),
@@ -235,6 +243,18 @@ private fun MemberLabelScreenUi(
messageText = stringResource(R.string.GroupMemberLabel__preview_sample_message)
)
}
if (state.membersWithLabels.isNotEmpty()) {
Text(
text = stringResource(R.string.GroupMemberLabel__group_members_with_labels),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 44.dp, bottom = 12.dp)
)
state.membersWithLabels.forEach {
MemberWithLabelRow(member = it)
}
}
}
CircularProgressWrapper(
@@ -322,6 +342,54 @@ private fun EmojiPickerButton(
}
}
@Composable
private fun MemberWithLabelRow(
member: GroupMemberWithLabel,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val tintColor = Color(member.nameColor.getColor(context))
Row(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarImage(
recipient = member.recipient,
modifier = Modifier.size(40.dp)
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
) {
Text(
text = member.recipient.getDisplayName(context),
style = MaterialTheme.typography.bodyLarge
)
MemberLabelPill(
emoji = member.label.emoji,
text = member.label.displayText,
tintColor = tintColor,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
textStyle = MemberLabelPill.textStyleCompact
)
}
if (member.isAdmin) {
Text(
text = stringResource(R.string.GroupRecipientListItem_admin),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
@Composable
private fun SaveButton(
enabled: Boolean,
@@ -331,6 +399,7 @@ private fun SaveButton(
Buttons.LargeTonal(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.filledTonalButtonColors(disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = modifier
) {
Text(text = stringResource(R.string.GroupMemberLabel__save))
@@ -359,13 +428,38 @@ private interface MemberLabelUiCallbacks {
@Composable
private fun MemberLabelScreenPreview() {
Previews.Preview {
val nameColor = NameColor(
lightColor = MaterialTheme.colorScheme.primary.toArgb(),
darkColor = MaterialTheme.colorScheme.primary.toArgb()
)
MemberLabelScreenUi(
state = MemberLabelUiState(
recipient = Recipient(
profileName = ProfileName.fromParts("Kahless", "The Unforgettable")
profileName = ProfileName.fromParts("Benjamin", "Sisko")
),
labelEmoji = "⛑️",
labelText = "Vet Coordinator"
labelEmoji = "",
labelText = "Captain",
membersWithLabels = listOf(
GroupMemberWithLabel(
recipient = Recipient(profileName = ProfileName.fromParts("Jadzia", "Dax")),
isAdmin = true,
label = MemberLabel(emoji = "🔬", text = "Science Officer"),
nameColor = nameColor
),
GroupMemberWithLabel(
recipient = Recipient(profileName = ProfileName.fromParts("Quark", "")),
isAdmin = false,
label = MemberLabel(emoji = "🍻", text = "Bartender/Entrepreneur"),
nameColor = nameColor
),
GroupMemberWithLabel(
recipient = Recipient(profileName = ProfileName.fromParts("Julian", "Bashir")),
isAdmin = true,
label = MemberLabel(emoji = null, text = "Chief Medical Officer"),
nameColor = nameColor
)
)
),
callbacks = MemberLabelUiCallbacks.Empty
)

View File

@@ -127,6 +127,34 @@ class MemberLabelRepository private constructor(
ColorizerV2(groupMemberIds).getNameColor(context, recipient)
}
/**
* Returns all group members who have labels set for the given group.
*/
suspend fun getMembersWithLabels(groupId: GroupId.V2): List<GroupMemberWithLabel> = withContext(Dispatchers.IO) {
if (!RemoteConfig.receiveMemberLabels) {
return@withContext emptyList()
}
val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext emptyList()
val groupProperties = groupRecord.requireV2GroupProperties()
val allMembers = groupProperties.getMemberRecipients(GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF)
val colorizer = ColorizerV2(groupMemberIds = allMembers.mapNotNull { it.serviceId.orNull() })
val labelsByAci = groupProperties.memberLabelsByAci()
allMembers.mapNotNull { member ->
val aci = member.serviceId.orNull() as? ServiceId.ACI
val label = labelsByAci[aci]?.sanitized() ?: return@mapNotNull null
GroupMemberWithLabel(
recipient = member,
isAdmin = groupProperties.isAdmin(member),
label = label,
nameColor = colorizer.getNameColor(context, member)
)
}
}
/**
* Sets the group member label for the current user.
*/

View File

@@ -16,13 +16,22 @@ 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.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveState
import org.thoughtcrime.securesms.groups.ui.GroupMemberOrder
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.NetworkResult
private val MEMBER_ORDER: Comparator<GroupMemberWithLabel> = GroupMemberOrder.comparator(
isSelf = { it.recipient.isSelf },
isAdmin = { it.isAdmin },
hasDisplayName = { it.recipient.hasAUserSetDisplayName(AppDependencies.application) },
getDisplayName = { it.recipient.getDisplayName(AppDependencies.application) }
)
class MemberLabelViewModel(
private val memberLabelRepo: MemberLabelRepository = MemberLabelRepository.instance,
private val groupId: GroupId.V2,
@@ -52,7 +61,8 @@ class MemberLabelViewModel(
recipient = recipient,
labelEmoji = originalLabelEmoji,
labelText = originalLabelText,
senderNameColor = memberLabelRepo.getSenderNameColor(groupId, recipient)
senderNameColor = memberLabelRepo.getSenderNameColor(groupId, recipient),
membersWithLabels = memberLabelRepo.getMembersWithLabels(groupId).sortedWith(MEMBER_ORDER)
)
}
}
@@ -169,6 +179,7 @@ data class MemberLabelUiState(
val recipient: Recipient? = null,
val senderNameColor: NameColor? = null,
val hasChanges: Boolean = false,
val membersWithLabels: List<GroupMemberWithLabel> = emptyList(),
val saveState: SaveState? = null,
val showAboutOverrideSheet: Boolean = false
) {

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.ui
import java.text.Collator
/**
* Comparator factory for sorting group members consistently across the app.
*
* The canonical sort order is: self first, then admins, then members with a user-set display name, then alphabetical by display name.
*/
object GroupMemberOrder {
private val collator: Collator = Collator.getInstance()
.apply { strength = Collator.PRIMARY }
/**
* Creates a [Comparator] for any type [T] using the canonical group member sort order.
*
* The caller supplies all sort-keys, so this utility can be applied to any data type that models a group member.
*/
@JvmStatic
fun <T> comparator(
isSelf: (T) -> Boolean,
isAdmin: (T) -> Boolean,
hasDisplayName: (T) -> Boolean,
getDisplayName: (T) -> String
): Comparator<T> = compareBy<T> { !isSelf(it) }
.thenBy { !isAdmin(it) }
.thenBy { !hasDisplayName(it) }
.thenBy { collator.getCollationKey(getDisplayName(it)) }
}

View File

@@ -9545,6 +9545,8 @@
<string name="GroupMemberLabel__accessibility_close_screen">Close screen</string>
<!-- Accessibility label for the group member label text field clear button. -->
<string name="GroupMemberLabel__accessibility_clear_label">Clear label</string>
<!-- Section header for the list of group members who have labels set. -->
<string name="GroupMemberLabel__group_members_with_labels">Group members with labels</string>
<!-- Title for the member labels education sheet. -->
<string name="MemberLabelsEducation__title">Member labels</string>

View File

@@ -44,6 +44,7 @@ class MemberLabelViewModelTest {
fun setUp() {
coEvery { memberLabelRepo.getRecipient(any()) } returns mockk(relaxed = true)
coEvery { memberLabelRepo.getSenderNameColor(any(), any()) } returns NameColor(0, 0)
coEvery { memberLabelRepo.getMembersWithLabels(any()) } returns emptyList()
every { memberLabelRepo.hasDismissedMemberLabelAboutOverrideWarning() } returns false
}