Add ability to edit member label from the about you sheet.

This commit is contained in:
jeffrey-signal
2026-02-19 11:22:55 -05:00
committed by Cody Henthorne
parent bd121e47c8
commit 28c37cb3ac
8 changed files with 186 additions and 20 deletions

View File

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

View File

@@ -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<GroupLinkUrlAndStatus> getGroupLink() {

View File

@@ -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.
*/

View File

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

View File

@@ -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<Optional<MemberLabel>> = rxSingle {
Optional.ofNullable(MemberLabelRepository.instance.getLabel(groupId, Recipient.self()))
}
fun canEditMemberLabel(groupId: GroupId.V2): Single<Boolean> = rxSingle {
MemberLabelRepository.instance.canSetLabel(groupId, Recipient.self())
}
}

View File

@@ -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<Optional<Recipient>> = mutableStateOf(Optional.empty())
@@ -33,6 +38,14 @@ class AboutSheetViewModel(
private val _verified: MutableState<Boolean> = mutableStateOf(false)
val verified: State<Boolean> = _verified
private val _memberLabel: MutableState<MemberLabel?> = mutableStateOf(null)
val memberLabel: State<MemberLabel?> = _memberLabel
private val _canEditMemberLabel: MutableState<Boolean> = mutableStateOf(false)
val canEditMemberLabel: State<Boolean> = _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()
}
}

View File

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

View File

@@ -2683,6 +2683,8 @@
<string name="AboutSheet__you">You</string>
<!-- Displays the name of a contact. The first placeholder is the name the user has assigned to that contact, the second name is the name the contact assigned to themselves -->
<string name="AboutSheet__user_set_display_name_and_profile_name">%1$s (%2$s)</string>
<!-- Placeholder text in the group member label row when the user has no label set. -->
<string name="AboutSheet__add_group_member_label">Add a member label</string>
<!-- Title of the screen showing which groups they have in common with another user. -->
<plurals name="GroupsInCommon__n_groups_in_common_title">