Show preview on edit member label screen.

This commit is contained in:
jeffrey-signal
2026-02-24 10:46:39 -05:00
committed by Cody Henthorne
parent a3fce4c149
commit a8a6fec19d
9 changed files with 314 additions and 62 deletions

View File

@@ -4,6 +4,7 @@ import android.content.Context
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import org.signal.core.models.ServiceId
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -16,7 +17,7 @@ import org.signal.core.ui.R as CoreUiR
* - Gives easy access to different bubble colors
* - Watches and responds to RecyclerView scroll and layout changes to update a ColorizerView
*/
class Colorizer {
class Colorizer @JvmOverloads constructor(groupMemberIds: List<ServiceId> = emptyList()) {
private var colorsHaveBeenSet = false
@@ -25,6 +26,10 @@ class Colorizer {
private val groupMembers: LinkedHashSet<ServiceId> = linkedSetOf()
init {
onGroupMembershipChanged(groupMemberIds)
}
@ColorInt
fun getOutgoingBodyTextColor(context: Context): Int {
return ContextCompat.getColor(context, R.color.conversation_outgoing_body_color)
@@ -67,29 +72,31 @@ class Colorizer {
}
}
@Suppress("DEPRECATION")
@ColorInt
fun getIncomingGroupSenderColor(context: Context, recipient: Recipient): Int {
return if (groupMembers.isEmpty()) {
groupSenderColors[recipient.id]?.getColor(context) ?: getDefaultColor(context, recipient)
} else if (recipient.hasServiceId) {
val memberPosition = groupMembers.indexOf(recipient.requireServiceId())
if (memberPosition >= 0) {
val colorPosition = memberPosition % ChatColorsPalette.Names.all.size
ChatColorsPalette.Names.all[colorPosition].getColor(context)
} else {
getDefaultColor(context, recipient)
}
} else {
getDefaultColor(context, recipient)
}
return getNameColor(context, recipient).getColor(context)
}
fun onGroupMembershipChanged(serviceIds: List<ServiceId>) {
groupMembers.addAll(serviceIds.sortedBy { it.toString() })
}
@Suppress("DEPRECATION")
fun getNameColor(context: Context, recipient: Recipient): NameColor {
if (groupMembers.isEmpty()) {
return groupSenderColors[recipient.id] ?: getDefaultColor(context, recipient)
}
val serviceId = recipient.serviceId.orNull()
if (serviceId != null) {
val position = groupMembers.indexOf(serviceId)
if (position >= 0) {
return ChatColorsPalette.Names.all[position % ChatColorsPalette.Names.all.size]
}
}
return getDefaultColor(context, recipient)
}
@Suppress("DEPRECATION")
@Deprecated("Not needed for CFv2", ReplaceWith("onGroupMembershipChanged"))
fun onNameColorsChanged(nameColorMap: Map<RecipientId, NameColor>) {
@@ -99,14 +106,13 @@ class Colorizer {
}
@Suppress("DEPRECATION")
@ColorInt
private fun getDefaultColor(context: Context, recipient: Recipient): Int {
private fun getDefaultColor(context: Context, recipient: Recipient): NameColor {
return if (colorsHaveBeenSet) {
val color = ChatColorsPalette.Names.all[groupSenderColors.size % ChatColorsPalette.Names.all.size]
groupSenderColors[recipient.id] = color
return color.getColor(context)
ChatColorsPalette.Names.all[groupSenderColors.size % ChatColorsPalette.Names.all.size]
.also { groupSenderColors[recipient.id] = it }
} else {
getIncomingBodyTextColor(context, recipient.hasWallpaper)
val colorInt = getIncomingBodyTextColor(context, recipient.hasWallpaper)
NameColor(lightColor = colorInt, darkColor = colorInt)
}
}
}

View File

@@ -6,13 +6,18 @@
package org.thoughtcrime.securesms.groups.memberlabel
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -46,6 +51,7 @@ import org.signal.core.util.isNotNullOrBlank
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveState
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.viewModel
@@ -126,39 +132,60 @@ private fun MemberLabelScreenUi(
keyboardController?.show()
}
Column(
Box(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize()
.imePadding()
) {
Text(
text = stringResource(R.string.GroupMemberLabel__description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 24.dp)
)
LabelTextField(
labelEmoji = state.labelEmoji,
labelText = state.labelText,
remainingCharacters = state.remainingCharacters,
onLabelTextChange = callbacks::onLabelTextChanged,
onEmojiChange = callbacks::onSetEmojiClicked,
onClear = callbacks::onClearLabelClicked,
onSave = callbacks::onSaveClicked,
Column(
modifier = Modifier
.padding(horizontal = 24.dp)
.focusRequester(focusRequester)
)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Text(
text = stringResource(R.string.GroupMemberLabel__description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp)
)
Spacer(modifier = Modifier.weight(1f))
LabelTextField(
labelEmoji = state.labelEmoji,
labelText = state.labelText,
remainingCharacters = state.remainingCharacters,
onLabelTextChange = callbacks::onLabelTextChanged,
onEmojiChange = callbacks::onSetEmojiClicked,
onClear = callbacks::onClearLabelClicked,
onSave = callbacks::onSaveClicked,
modifier = Modifier.focusRequester(focusRequester)
)
Text(
text = stringResource(R.string.GroupMemberLabel__preview_section_header),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 24.dp, bottom = 12.dp)
)
if (state.recipient != null) {
MessageBubblePreview(
sender = state.recipient,
senderNameColor = state.senderNameColor,
labelEmoji = state.labelEmoji,
labelText = state.labelText,
messageText = stringResource(R.string.GroupMemberLabel__preview_sample_message)
)
}
}
SaveButton(
enabled = state.isSaveEnabled,
onClick = callbacks::onSaveClicked,
modifier = Modifier
.align(Alignment.End)
.padding(24.dp)
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 16.dp)
)
}
}
@@ -269,6 +296,9 @@ private fun MemberLabelScreenPreview() {
Previews.Preview {
MemberLabelScreenUi(
state = MemberLabelUiState(
recipient = Recipient(
profileName = ProfileName.fromParts("Kahless", "The Unforgettable")
),
labelEmoji = "⛑️",
labelText = "Vet Coordinator"
),

View File

@@ -91,7 +91,7 @@ fun MemberLabelPill(
Text(
text = annotatedText,
inlineContent = inlineContent,
modifier = Modifier.padding(end = 5.dp)
modifier = if (text.isNotEmpty()) Modifier.padding(end = 5.dp) else Modifier
)
}
}

View File

@@ -12,6 +12,8 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.signal.core.models.ServiceId
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -33,11 +35,15 @@ class MemberLabelRepository private constructor(
val instance: MemberLabelRepository by lazy { MemberLabelRepository() }
}
suspend fun getRecipient(recipientId: RecipientId): Recipient = withContext(Dispatchers.IO) {
Recipient.resolved(recipientId)
}
/**
* Gets the member label for a specific recipient in the group.
*/
suspend fun getLabel(groupId: GroupId.V2, recipientId: RecipientId): MemberLabel? = withContext(Dispatchers.IO) {
getLabel(groupId, Recipient.resolved(recipientId))
suspend fun getLabel(groupId: GroupId.V2, recipientId: RecipientId): MemberLabel? {
return getLabel(groupId, getRecipient(recipientId))
}
/**
@@ -89,6 +95,19 @@ class MemberLabelRepository private constructor(
groupRecord.attributesAccessControl.allows(groupRecord.memberLevel(recipient))
}
/**
* Computes the sender [NameColor] for a recipient as seen by other group members.
*/
suspend fun getSenderNameColor(groupId: GroupId.V2, recipientId: RecipientId): NameColor = withContext(Dispatchers.IO) {
val recipient = getRecipient(recipientId)
val groupMemberIds = groupsTable
.getGroupMembers(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF)
.mapNotNull { it.serviceId.orNull() }
Colorizer(groupMemberIds).getNameColor(context, recipient)
}
/**
* Sets the group member label for the current user.
*/

View File

@@ -13,8 +13,10 @@ 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.conversation.colors.NameColor
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveState
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
private const val MIN_LABEL_TEXT_LENGTH = 1
@@ -33,10 +35,10 @@ class MemberLabelViewModel(
val uiState: StateFlow<MemberLabelUiState> = internalUiState.asStateFlow()
init {
loadExistingLabel()
loadInitialState()
}
private fun loadExistingLabel() {
private fun loadInitialState() {
viewModelScope.launch(SignalDispatchers.IO) {
val memberLabel = memberLabelRepo.getLabel(groupId, recipientId)
originalLabelEmoji = memberLabel?.emoji.orEmpty()
@@ -44,8 +46,10 @@ class MemberLabelViewModel(
internalUiState.update {
it.copy(
recipient = memberLabelRepo.getRecipient(recipientId),
labelEmoji = originalLabelEmoji,
labelText = originalLabelText
labelText = originalLabelText,
senderNameColor = memberLabelRepo.getSenderNameColor(groupId, recipientId)
)
}
}
@@ -119,6 +123,8 @@ class MemberLabelViewModel(
data class MemberLabelUiState(
val labelEmoji: String = "",
val labelText: String = "",
val recipient: Recipient? = null,
val senderNameColor: NameColor? = null,
val hasChanges: Boolean = false,
val saveState: SaveState? = null
) {

View File

@@ -0,0 +1,180 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.memberlabel
import android.content.Context
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import androidx.annotation.ColorInt
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.conversation.v2.items.SenderNameWithLabelView
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.visible
import org.signal.core.ui.R as CoreUiR
@Composable
fun MessageBubblePreview(
sender: Recipient,
senderNameColor: NameColor?,
labelEmoji: String?,
labelText: String?,
messageText: String,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val senderNameColorInt = senderNameColor?.getColor(context) ?: MaterialTheme.colorScheme.onSurface.toArgb()
Box(
modifier = modifier
.widthIn(max = 600.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(27.dp))
.background(SignalTheme.colors.colorSurface2)
.padding(start = 4.dp, end = 16.dp, top = 20.dp, bottom = 20.dp)
) {
if (LocalInspectionMode.current) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.height(96.dp)
) {
Text(
text = "<MessageBubblePreview>",
fontStyle = FontStyle.Italic
)
}
} else {
AndroidView(
factory = ::MessageBubblePreviewView,
update = { view ->
view.setData(
context = view.context,
sender = sender,
senderNameColor = senderNameColorInt,
labelEmoji = labelEmoji,
labelText = labelText,
messageText = messageText
)
},
modifier = modifier
)
}
}
}
private class MessageBubblePreviewView(context: Context) : FrameLayout(context) {
private val avatarView: AvatarImageView
private val groupSenderView: SenderNameWithLabelView
private val bodyBubble: View
private val bodyText: EmojiTextView
init {
LayoutInflater.from(context).inflate(R.layout.v2_conversation_item_text_only_incoming, this, true)
avatarView = findViewById(R.id.contact_photo)
groupSenderView = findViewById(R.id.group_sender_name_with_label)
bodyBubble = findViewById(R.id.conversation_item_body_wrapper)
bodyText = findViewById(R.id.conversation_item_body)
bodyBubble.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = DimensionUnit.DP.toPixels(18f)
setColor(ContextCompat.getColor(context, CoreUiR.color.signal_colorSurface))
}
bodyBubble.minimumWidth = DimensionUnit.DP.toPixels(180f).toInt()
// match the appearance of V2ConversationItemTextOnlyViewHolder when a member label is shown:
(bodyBubble.layoutParams as? MarginLayoutParams)?.marginEnd = 0
(bodyText.layoutParams as? MarginLayoutParams)?.topMargin = 0
findViewById<View>(R.id.conversation_item_reply)?.visible = false
findViewById<View>(R.id.conversation_item_reactions)?.visible = false
}
fun setData(
context: Context,
sender: Recipient,
@ColorInt senderNameColor: Int,
labelEmoji: String?,
labelText: String?,
messageText: String
) {
avatarView.visible = true
avatarView.setAvatarUsingProfile(sender)
groupSenderView.visible = true
groupSenderView.setSender(sender.getDisplayName(context), senderNameColor)
val memberLabel = if (labelEmoji.isNullOrBlank() && labelText.isNullOrBlank()) {
null
} else {
MemberLabel(emoji = labelEmoji, text = labelText.orEmpty())
}
groupSenderView.setLabel(memberLabel)
bodyText.text = messageText
bodyText.setTextColor(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface))
bodyText.visible = true
}
}
@DayNightPreviews
@Composable
private fun MessageBubblePreviewPreview() {
Previews.Preview {
Box(
modifier = Modifier
.background(Color.Black)
.padding(20.dp)
) {
MessageBubblePreview(
sender = Recipient(
profileName = ProfileName.fromParts("Kahless", "The Unforgettable")
),
senderNameColor = NameColor(
lightColor = MaterialTheme.colorScheme.onSurface.toArgb(),
darkColor = MaterialTheme.colorScheme.onSurface.toArgb()
),
labelEmoji = "⚔️️",
labelText = "Legendary Warrior",
messageText = "Questions are the beginning of wisdom, the mark of a true warrior."
)
}
}
}

View File

@@ -9352,6 +9352,8 @@
<string name="GroupMemberLabel__label_text_placeholder">Add your label</string>
<!-- Group member label preview section header. -->
<string name="GroupMemberLabel__preview_section_header">Preview</string>
<!-- Sample message shown in the group member label message preview. -->
<string name="GroupMemberLabel__preview_sample_message">Hello!</string>
<!-- Group member label save button label. -->
<string name="GroupMemberLabel__save">Save</string>
<!-- Accessibility label for the button to open the group member label emoji picker. -->

View File

@@ -13,8 +13,10 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
@@ -30,6 +32,12 @@ class MemberLabelViewModelTest {
private val groupId = mockk<GroupId.V2>()
private val recipientId = RecipientId.from(1L)
@Before
fun setUp() {
coEvery { memberLabelRepo.getRecipient(any()) } returns mockk(relaxed = true)
coEvery { memberLabelRepo.getSenderNameColor(any(), any()) } returns NameColor(0, 0)
}
@Test
fun `isSaveEnabled returns true when label text is different from the original value`() {
coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")

View File

@@ -6,6 +6,7 @@
package org.signal.core.ui.compose
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column
@@ -30,6 +31,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
@@ -151,18 +153,17 @@ fun ClearableTextField(
trailingIcon = if (clearable) clearButton else null
)
AnimatedVisibility(
visible = displayCountdown,
modifier = Modifier.align(Alignment.End)
) {
val errorThresholdExceeded = countdownConfig != null && charactersRemainingBeforeLimit <= countdownConfig.warnThreshold
Text(
text = "$charactersRemainingBeforeLimit",
style = MaterialTheme.typography.bodySmall,
color = if (errorThresholdExceeded) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(top = 4.dp, end = 16.dp)
)
}
val countdownAlpha by animateFloatAsState(targetValue = if (displayCountdown) 1f else 0f, label = "countdownAlpha")
val errorThresholdExceeded = countdownConfig != null && charactersRemainingBeforeLimit <= countdownConfig.warnThreshold
Text(
text = "$charactersRemainingBeforeLimit",
style = MaterialTheme.typography.bodySmall,
color = if (errorThresholdExceeded) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline,
modifier = Modifier
.align(Alignment.End)
.alpha(countdownAlpha)
.padding(top = 4.dp, end = 16.dp)
)
}
}