From 28c37cb3ac7f1c943051c58853bc984f7c8d7126 Mon Sep 17 00:00:00 2001 From: jeffrey-signal Date: Thu, 19 Feb 2026 11:22:55 -0500 Subject: [PATCH] Add ability to edit member label from the about you sheet. --- .../securesms/groups/GroupAccessControl.java | 13 ++ .../securesms/groups/LiveGroup.java | 7 +- .../memberlabel/MemberLabelRepository.kt | 9 ++ .../recipients/ui/about/AboutSheet.kt | 119 ++++++++++++++++-- .../ui/about/AboutSheetRepository.kt | 13 ++ .../ui/about/AboutSheetViewModel.kt | 40 +++++- .../RecipientBottomSheetDialogFragment.kt | 3 +- app/src/main/res/values/strings.xml | 2 + 8 files changed, 186 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java index 7a0d7252d7..41c8a36474 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java @@ -1,8 +1,10 @@ package org.thoughtcrime.securesms.groups; +import androidx.annotation.NonNull; import androidx.annotation.StringRes; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.GroupTable; public enum GroupAccessControl { ALL_MEMBERS(R.string.GroupManagement_access_level_all_members), @@ -18,4 +20,15 @@ public enum GroupAccessControl { public @StringRes int getString() { return string; } + + /** + * Returns true if the given [memberLevel] meets this access requirement. + */ + public boolean allows(@NonNull GroupTable.MemberLevel memberLevel) { + return switch (this) { + case ALL_MEMBERS -> memberLevel.isInGroup(); + case ONLY_ADMINS -> memberLevel == GroupTable.MemberLevel.ADMINISTRATOR; + case NO_ONE -> false; + }; + } } 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 c639f2d4e6..6023c8b379 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -243,12 +243,7 @@ public final class LiveGroup { } private static boolean applyAccessControl(@NonNull GroupTable.MemberLevel memberLevel, @NonNull GroupAccessControl rights) { - switch (rights) { - case ALL_MEMBERS: return memberLevel.isInGroup(); - case ONLY_ADMINS: return memberLevel == GroupTable.MemberLevel.ADMINISTRATOR; - case NO_ONE : return false; - default: throw new AssertionError(); - } + return rights.allows(memberLevel); } public LiveData getGroupLink() { 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 4154981282..aaed9249c3 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 @@ -80,6 +80,15 @@ class MemberLabelRepository private constructor( } } + /** + * Checks whether the [Recipient] has permission to set their member label in the given group. + */ + suspend fun canSetLabel(groupId: GroupId.V2, recipient: Recipient): Boolean = withContext(Dispatchers.IO) { + if (!RemoteConfig.sendMemberLabels) return@withContext false + val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext false + groupRecord.attributesAccessControl.allows(groupRecord.memberLevel(recipient)) + } + /** * Sets the group member label for the current user. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt index 90ef64f3e5..6123d30f30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.recipients.ui.about import android.content.res.Configuration +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -39,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.os.bundleOf import androidx.core.widget.TextViewCompat +import androidx.navigation.Navigation import org.signal.core.ui.compose.BottomSheets import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment import org.signal.core.ui.compose.DayNightPreviews @@ -50,7 +52,10 @@ import org.thoughtcrime.securesms.AvatarPreviewActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.avatar.AvatarImage import org.thoughtcrime.securesms.components.emoji.EmojiTextView +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsFragmentDirections import org.thoughtcrime.securesms.conversation.v2.UnverifiedProfileNameBottomSheet +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.memberlabel.MemberLabel import org.thoughtcrime.securesms.groups.ui.incommon.GroupsInCommonActivity import org.thoughtcrime.securesms.nicknames.ViewNoteSheet import org.thoughtcrime.securesms.recipients.Recipient @@ -66,14 +71,15 @@ import org.signal.core.ui.R as CoreUiR class AboutSheet : ComposeBottomSheetDialogFragment() { companion object { - private const val RECIPIENT_ID = "recipient_id" + private const val VIEWING_FROM_GROUP_ID = "viewing_from_group_id" @JvmStatic - fun create(recipient: Recipient): AboutSheet { + fun create(recipient: Recipient, viewingFromGroupId: GroupId.V2? = null): AboutSheet { return AboutSheet().apply { arguments = bundleOf( - RECIPIENT_ID to recipient.id + RECIPIENT_ID to recipient.id, + VIEWING_FROM_GROUP_ID to viewingFromGroupId ) } } @@ -81,11 +87,11 @@ class AboutSheet : ComposeBottomSheetDialogFragment() { override val peekHeightPercentage: Float = 1f - private val recipientId: RecipientId - get() = requireArguments().getParcelableCompat(RECIPIENT_ID, RecipientId::class.java)!! + private val recipientId: RecipientId by lazy { requireArguments().getParcelableCompat(RECIPIENT_ID, RecipientId::class.java)!! } + private val viewingFromGroupId: GroupId.V2? by lazy { requireArguments().getParcelableCompat(VIEWING_FROM_GROUP_ID, GroupId.V2::class.java) } private val viewModel by viewModel { - AboutSheetViewModel(recipientId) + AboutSheetViewModel(recipientId, viewingFromGroupId) } @Composable @@ -93,6 +99,8 @@ class AboutSheet : ComposeBottomSheetDialogFragment() { val recipient by viewModel.recipient val groupsInCommonCount by viewModel.groupsInCommonCount val verified by viewModel.verified + val memberLabel by viewModel.memberLabel + val canEditMemberLabel by viewModel.canEditMemberLabel if (recipient.isPresent) { Content( @@ -113,13 +121,16 @@ class AboutSheet : ComposeBottomSheetDialogFragment() { profileSharing = recipient.get().isProfileSharing, systemContact = recipient.get().isSystemContact, groupsInCommon = groupsInCommonCount, - note = recipient.get().note ?: "" + note = recipient.get().note ?: "", + memberLabel = memberLabel, + canEditMemberLabel = canEditMemberLabel ), onClickSignalConnections = this::openSignalConnectionsSheet, onAvatarClicked = this::openProfilePhotoViewer, onNoteClicked = this::openNoteSheet, onUnverifiedProfileClicked = this::openUnverifiedProfileSheet, - onGroupsInCommonClicked = this::openGroupsInCommon + onGroupsInCommonClicked = this::openGroupsInCommon, + onMemberLabelClicked = this::openMemberLabelScreen ) } } @@ -147,6 +158,14 @@ class AboutSheet : ComposeBottomSheetDialogFragment() { dismiss() startActivity(GroupsInCommonActivity.createIntent(requireContext(), recipientId)) } + + private fun openMemberLabelScreen() { + viewingFromGroupId?.let { groupId -> + val navController = Navigation.findNavController(requireActivity(), R.id.nav_host_fragment) + navController.navigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId)) + dismiss() + } + } } private data class AboutModel( @@ -162,7 +181,9 @@ private data class AboutModel( val profileSharing: Boolean, val systemContact: Boolean, val groupsInCommon: Int, - val note: String + val note: String, + val memberLabel: MemberLabel? = null, + val canEditMemberLabel: Boolean = false ) @Composable @@ -172,7 +193,8 @@ private fun Content( onAvatarClicked: () -> Unit, onNoteClicked: () -> Unit, onUnverifiedProfileClicked: () -> Unit = {}, - onGroupsInCommonClicked: () -> Unit = {} + onGroupsInCommonClicked: () -> Unit = {}, + onMemberLabelClicked: () -> Unit = {} ) { Box( contentAlignment = Alignment.Center, @@ -218,6 +240,15 @@ private fun Content( modifier = Modifier.fillMaxWidth() ) + if (model.isSelf && (model.memberLabel != null || model.canEditMemberLabel)) { + MemberLabelRow( + memberLabel = model.memberLabel, + canEdit = model.canEditMemberLabel, + onClick = onMemberLabelClicked, + modifier = Modifier.fillMaxWidth() + ) + } + if (model.about.isNotNullOrBlank()) { val textColor = LocalContentColor.current @@ -333,6 +364,48 @@ private fun Content( } } +@Composable +private fun MemberLabelRow( + memberLabel: MemberLabel?, + canEdit: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + AboutRow( + startIcon = ImageVector.vectorResource(R.drawable.symbol_tag_24), + text = { + if (memberLabel != null) { + if (!memberLabel.emoji.isNullOrEmpty()) { + Text( + text = memberLabel.emoji, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.size(4.dp)) + } + + Text( + text = memberLabel.text, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, false) + ) + } else { + Text( + text = stringResource(id = R.string.AboutSheet__add_group_member_label), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, false) + ) + } + }, + endIcon = if (canEdit) ImageVector.vectorResource(id = R.drawable.symbol_chevron_right_compact_bold_16) else null, + onClick = if (canEdit) onClick else null, + modifier = modifier + ) +} + @Composable private fun AboutRow( startIcon: ImageVector, @@ -485,6 +558,8 @@ private fun ContentPreviewForSelf() { profileSharing = true, systemContact = true, groupsInCommon = 0, + memberLabel = MemberLabel("🕷️", "Superhero"), + canEditMemberLabel = true, note = "Weird Things Happen To Me All The Time." ), onClickSignalConnections = {}, @@ -628,3 +703,27 @@ private fun AboutRowPreview() { } } } + +@DayNightPreviews +@Composable +private fun MemberLabelRowPreviews() = Previews.Preview { + val headerModifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(vertical = 4.dp) + .padding(horizontal = 8.dp) + + Column { + Text("no label, can't edit:", style = MaterialTheme.typography.labelSmall, modifier = headerModifier) + MemberLabelRow(memberLabel = null, canEdit = false, onClick = {}) + + Text("no label, editable:", style = MaterialTheme.typography.labelSmall, modifier = headerModifier) + MemberLabelRow(memberLabel = null, canEdit = true, onClick = {}) + + Text("has label, can't edit:", style = MaterialTheme.typography.labelSmall, modifier = headerModifier) + MemberLabelRow(memberLabel = MemberLabel("🕷️", "Superhero"), canEdit = false, onClick = {}) + + Text("has label, editable:", style = MaterialTheme.typography.labelSmall, modifier = headerModifier) + MemberLabelRow(memberLabel = MemberLabel("🕷️", "Superhero"), canEdit = true, onClick = {}) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheetRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheetRepository.kt index 847437230f..fa424372ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheetRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheetRepository.kt @@ -10,8 +10,13 @@ import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.rx3.rxSingle import org.thoughtcrime.securesms.database.IdentityTable import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.GroupsInCommonRepository +import org.thoughtcrime.securesms.groups.memberlabel.MemberLabel +import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelRepository +import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import java.util.Optional class AboutSheetRepository { @@ -25,4 +30,12 @@ class AboutSheetRepository { identityRecord.isPresent && identityRecord.get().verifiedStatus == IdentityTable.VerifiedStatus.VERIFIED }.subscribeOn(Schedulers.io()) } + + fun getMemberLabel(groupId: GroupId.V2): Single> = rxSingle { + Optional.ofNullable(MemberLabelRepository.instance.getLabel(groupId, Recipient.self())) + } + + fun canEditMemberLabel(groupId: GroupId.V2): Single = rxSingle { + MemberLabelRepository.instance.canSetLabel(groupId, Recipient.self()) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheetViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheetViewModel.kt index 51716a7b1e..57ef64a14e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheetViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheetViewModel.kt @@ -13,15 +13,20 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.kotlin.subscribeBy +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.memberlabel.MemberLabel import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.RemoteConfig import java.util.Optional class AboutSheetViewModel( recipientId: RecipientId, - repository: AboutSheetRepository = AboutSheetRepository() + groupId: GroupId.V2? = null, + private val repository: AboutSheetRepository = AboutSheetRepository() ) : ViewModel() { private val _recipient: MutableState> = mutableStateOf(Optional.empty()) @@ -33,6 +38,14 @@ class AboutSheetViewModel( private val _verified: MutableState = mutableStateOf(false) val verified: State = _verified + private val _memberLabel: MutableState = mutableStateOf(null) + val memberLabel: State = _memberLabel + + private val _canEditMemberLabel: MutableState = mutableStateOf(false) + val canEditMemberLabel: State = _canEditMemberLabel + + private val disposables = CompositeDisposable() + private val recipientDisposable: Disposable = Recipient .observable(recipientId) .observeOn(AndroidSchedulers.mainThread()) @@ -54,8 +67,29 @@ class AboutSheetViewModel( _verified.value = it } + init { + disposables.addAll(recipientDisposable, groupsInCommonDisposable, verifiedDisposable) + + if (groupId != null && RemoteConfig.sendMemberLabels) { + observeMemberLabel(groupId) + } + } + + private fun observeMemberLabel(groupId: GroupId.V2) { + disposables.add( + repository.getMemberLabel(groupId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy { _memberLabel.value = it.orElse(null) } + ) + + disposables.add( + repository.canEditMemberLabel(groupId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy { _canEditMemberLabel.value = it } + ) + } + override fun onCleared() { - recipientDisposable.dispose() - groupsInCommonDisposable.dispose() + disposables.dispose() } } 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 04897350f9..a4f5a7414a 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 @@ -70,7 +70,8 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr fun show(fragmentManager: FragmentManager, recipientId: RecipientId, groupId: GroupId?) { val recipient = Recipient.resolved(recipientId) if (recipient.isSelf) { - AboutSheet.create(recipient).show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + AboutSheet.create(recipient, groupId as? GroupId.V2) + .show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) } else { val args = Bundle() val fragment = RecipientBottomSheetDialogFragment() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 71719879a6..e770a5ce73 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2683,6 +2683,8 @@ You %1$s (%2$s) + + Add a member label