Add member labels education sheet.

This commit is contained in:
jeffrey-signal
2026-02-25 17:24:31 -05:00
committed by Cody Henthorne
parent 955bcde062
commit 0b2d3edcce
12 changed files with 385 additions and 10 deletions

View File

@@ -244,6 +244,10 @@ sealed class GroupId(private val encodedId: String) : DatabaseId, Parcelable {
return this as V2
}
fun v2OrNull(): V2? {
return if (isV2) (this as V2) else null
}
fun requirePush(): Push {
assert(this is Push)
return this as Push

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.memberlabel
import android.content.Context
import android.content.Intent
import android.os.Bundle
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.groups.GroupId
/**
* Hosts [MemberLabelFragment], allowing navigation to the member label editor from any context.
*/
class MemberLabelActivity : PassphraseRequiredActivity() {
companion object {
private const val EXTRA_GROUP_ID = "group_id"
fun createIntent(context: Context, groupId: GroupId.V2): Intent {
return Intent(context, MemberLabelActivity::class.java).apply {
putExtra(EXTRA_GROUP_ID, groupId)
}
}
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
if (savedInstanceState == null) {
val groupId = intent.getParcelableExtraCompat(EXTRA_GROUP_ID, GroupId.V2::class.java)!!
val fragment = MemberLabelFragment.newInstance(groupId)
supportFragmentManager.beginTransaction()
.replace(android.R.id.content, fragment)
.commit()
}
}
}

View File

@@ -0,0 +1,199 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.memberlabel
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.util.requireParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.util.viewModel
/**
* Explains what member labels are and provides options to edit the current user's label.
*/
class MemberLabelEducationSheet : ComposeBottomSheetDialogFragment() {
companion object {
const val RESULT_EDIT_MEMBER_LABEL = "edit_member_label"
const val KEY_GROUP_ID = "group_id"
private const val FRAGMENT_TAG = "MemberLabelEducationSheet"
private const val ARGS_GROUP_ID = "group_id"
fun show(fragmentManager: FragmentManager, groupId: GroupId.V2) {
val fragment = MemberLabelEducationSheet().apply {
arguments = bundleOf(ARGS_GROUP_ID to groupId)
}
fragment.show(fragmentManager, FRAGMENT_TAG)
}
}
private val groupId: GroupId.V2 by lazy {
requireArguments().requireParcelableCompat(ARGS_GROUP_ID, GroupId.V2::class.java)
}
private val viewModel: MemberLabelEducationViewModel by viewModel {
MemberLabelEducationViewModel(groupId)
}
override val peekHeightPercentage: Float = 1f
@Composable
override fun SheetContent() {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val callbacks = remember {
object : MemberLabelEducationUiCallbacks {
override fun onSetLabelClicked() {
setFragmentResult(RESULT_EDIT_MEMBER_LABEL, bundleOf(KEY_GROUP_ID to groupId))
dismiss()
}
override fun onDismiss() = dismiss()
}
}
MemberLabelEducationSheetContent(
state = state,
callbacks = callbacks
)
}
}
@Composable
private fun MemberLabelEducationSheetContent(
state: MemberLabelEducationViewModel.UiState,
callbacks: MemberLabelEducationUiCallbacks = MemberLabelEducationUiCallbacks.Empty
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(top = 4.dp, bottom = 28.dp, start = 28.dp, end = 28.dp)
.verticalScroll(rememberScrollState())
) {
BottomSheets.Handle()
Image(
painter = painterResource(R.drawable.symbol_tag_filled_64),
contentDescription = null,
modifier = Modifier
.padding(top = 24.dp)
.size(64.dp)
)
Text(
text = stringResource(R.string.MemberLabelsEducation__title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = stringResource(R.string.MemberLabelsEducation__body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp)
)
if (state.selfCanSetLabel) {
TextButton(
onClick = callbacks::onSetLabelClicked,
modifier = Modifier.padding(top = 56.dp)
) {
Text(
text = stringResource(
if (state.selfHasLabel) R.string.MemberLabelsEducation__edit_label
else R.string.MemberLabelsEducation__set_label
)
)
}
} else {
Spacer(modifier = Modifier.height(56.dp))
}
Buttons.LargeTonal(
onClick = callbacks::onDismiss,
modifier = Modifier
.padding(top = 16.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = stringResource(android.R.string.ok))
}
}
}
private interface MemberLabelEducationUiCallbacks {
fun onSetLabelClicked()
fun onDismiss()
object Empty : MemberLabelEducationUiCallbacks {
override fun onSetLabelClicked() = Unit
override fun onDismiss() = Unit
}
}
@AllDevicePreviews
@Composable
private fun MemberLabelEducationSheetPreviewCanSetNoLabel() = Previews.Preview {
MemberLabelEducationSheetContent(
state = MemberLabelEducationViewModel.UiState(
selfHasLabel = false,
selfCanSetLabel = true
)
)
}
@DayNightPreviews
@Composable
private fun MemberLabelEducationSheetPreviewCanSetHasLabel() = Previews.Preview {
MemberLabelEducationSheetContent(
state = MemberLabelEducationViewModel.UiState(
selfHasLabel = true,
selfCanSetLabel = true
)
)
}
@DayNightPreviews
@Composable
private fun MemberLabelEducationSheetPreviewCannotSet() = Previews.Preview {
MemberLabelEducationSheetContent(
state = MemberLabelEducationViewModel.UiState(
selfHasLabel = false,
selfCanSetLabel = false
)
)
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.memberlabel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.util.concurrent.SignalDispatchers
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
class MemberLabelEducationViewModel(
private val groupId: GroupId.V2,
private val repository: MemberLabelRepository = MemberLabelRepository.instance
) : ViewModel() {
data class UiState(
val selfHasLabel: Boolean = false,
val selfCanSetLabel: Boolean = false
)
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
init {
viewModelScope.launch(SignalDispatchers.IO) {
val self = Recipient.self()
val selfMemberLabel = repository.getLabel(groupId, self.id)
val selfCanSetLabel = repository.canSetLabel(groupId, self)
_uiState.update {
it.copy(
selfHasLabel = selfMemberLabel != null,
selfCanSetLabel = selfCanSetLabel
)
}
}
}
}

View File

@@ -38,8 +38,8 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.navArgs
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ClearableTextField
@@ -48,6 +48,7 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
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.groups.GroupId
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveState
@@ -62,12 +63,22 @@ import org.thoughtcrime.securesms.util.viewModel
class MemberLabelFragment : ComposeFragment(), ReactWithAnyEmojiBottomSheetDialogFragment.Callback {
companion object {
private const val EMOJI_PICKER_DIALOG_TAG = "emoji_picker_dialog"
private const val ARG_GROUP_ID = "group_id"
fun newInstance(groupId: GroupId.V2): MemberLabelFragment {
return MemberLabelFragment().apply {
arguments = bundleOf(ARG_GROUP_ID to groupId)
}
}
}
private val groupId: GroupId.V2 by lazy {
requireArguments().requireParcelableCompat(ARG_GROUP_ID, GroupId.V2::class.java)
}
private val args: MemberLabelFragmentArgs by navArgs()
private val viewModel: MemberLabelViewModel by viewModel {
MemberLabelViewModel(
groupId = (args.groupId as GroupId).requireV2(),
groupId = groupId,
recipientId = Recipient.self().id
)
}