mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-02 08:23:00 +01:00
Show other group members with labels.
This commit is contained in:
@@ -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 -> {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user