diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java index 79a1bfacfb..91f2e20ac8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -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 LOCAL_FIRST = (m1, m2) -> Boolean.compare(m2.getMember().isSelf(), m1.getMember().isSelf()); - private static final Comparator ADMIN_FIRST = (m1, m2) -> Boolean.compare(m2.isAdmin(), m1.isAdmin()); - private static final Comparator HAS_DISPLAY_NAME = (m1, m2) -> Boolean.compare(m2.getMember().hasAUserSetDisplayName(AppDependencies.getApplication()), m1.getMember().hasAUserSetDisplayName(AppDependencies.getApplication())); - private static final Comparator ALPHABETICAL = (m1, m2) -> collator.compare(m1.getMember().getDisplayName(AppDependencies.getApplication()).toLowerCase(), m2.getMember().getDisplayName(AppDependencies.getApplication()).toLowerCase()); - private static final Comparator MEMBER_ORDER = ComparatorCompat.chain(LOCAL_FIRST) - .thenComparing(ADMIN_FIRST) - .thenComparing(HAS_DISPLAY_NAME) - .thenComparing(ALPHABETICAL); + private static final Comparator 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; @@ -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 v2Properties = Transformations.map(this.groupRecord, GroupRecord::requireV2GroupProperties); this.groupLink = Transformations.map(v2Properties, g -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabel.kt index bee24ef873..9e7e4502bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabel.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt index 0ff98fcca2..7016252cf1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt @@ -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 ) 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 4c871e79d7..2715f9006b 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 @@ -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 = 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. */ 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 ea094038ad..83b31158ff 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 @@ -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 = 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 = emptyList(), val saveState: SaveState? = null, val showAboutOverrideSheet: Boolean = false ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberOrder.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberOrder.kt new file mode 100644 index 0000000000..93ea254e05 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberOrder.kt @@ -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 comparator( + isSelf: (T) -> Boolean, + isAdmin: (T) -> Boolean, + hasDisplayName: (T) -> Boolean, + getDisplayName: (T) -> String + ): Comparator = compareBy { !isSelf(it) } + .thenBy { !isAdmin(it) } + .thenBy { !hasDisplayName(it) } + .thenBy { collator.getCollationKey(getDisplayName(it)) } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c49d2dd17..c9fcc2fb97 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9545,6 +9545,8 @@ Close screen Clear label + + Group members with labels Member labels diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt index 3a36df99ee..c503464a5b 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt @@ -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 }