Enforce member label emoji and text constraints.

This commit is contained in:
jeffrey-signal
2026-02-26 08:32:32 -05:00
committed by GitHub
parent 503bf04ec5
commit 316d0e67c5
10 changed files with 407 additions and 19 deletions

View File

@@ -6,6 +6,10 @@
package org.thoughtcrime.securesms.groups.memberlabel
import androidx.annotation.ColorInt
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
/**
* A member's custom label within a group.
@@ -13,7 +17,51 @@ import androidx.annotation.ColorInt
data class MemberLabel(
val emoji: String?,
val text: String
)
) {
/**
* The label text formatted for display.
*
* Use this in all rendering contexts. Use [text] only when comparing or persisting values.
*/
val displayText: String get() = BidiUtil.isolateBidi(text)
companion object {
const val MIN_LABEL_GRAPHEMES = 1
const val MAX_LABEL_GRAPHEMES = 24
const val MAX_LABEL_BYTES = 96
const val MAX_EMOJI_BYTES = 48
/**
* Truncates label [text] to the grapheme and byte limits without any whitespace normalization.
*/
@JvmStatic
fun truncateLabelText(text: String): String {
val breakIterator = BreakIteratorCompat.getInstance()
breakIterator.setText(text)
val graphemeTruncated = breakIterator.take(MAX_LABEL_GRAPHEMES).toString()
return StringUtil.trimToFit(graphemeTruncated, MAX_LABEL_BYTES)
}
/**
* Sanitizes and truncates label [text].
*/
@JvmStatic
fun sanitizeLabelText(text: String): String {
val collapsed = StringUtil.trimToVisualBounds(text.replace(Regex("\\s+"), " "))
return truncateLabelText(collapsed)
}
/**
* Returns [emoji] if it is a single valid emoji within [MAX_EMOJI_BYTES], otherwise null.
*/
@JvmStatic
fun sanitizeEmoji(emoji: String?): String? {
val trimmed = StringUtil.trimToFit(emoji, MAX_EMOJI_BYTES)
return trimmed.takeIf { it.isNotBlank() && EmojiUtil.isEmoji(it) }
}
}
}
data class StyledMemberLabel(
val label: MemberLabel,

View File

@@ -185,7 +185,7 @@ private fun MemberLabelScreenUi(
sender = state.recipient,
senderNameColor = state.senderNameColor,
labelEmoji = state.labelEmoji,
labelText = state.labelText,
labelText = state.sanitizedLabelText,
messageText = stringResource(R.string.GroupMemberLabel__preview_sample_message)
)
}

View File

@@ -53,7 +53,7 @@ class MemberLabelPillView : AbstractComposeView {
memberLabel?.let { label ->
MemberLabelPill(
emoji = label.emoji,
text = label.text,
text = label.displayText,
tintColor = tintColor,
modifier = Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding),
textStyle = style.textStyle()

View File

@@ -68,7 +68,7 @@ class MemberLabelRepository private constructor(
val aci = recipient.serviceId.orNull() as? ServiceId.ACI ?: return@withContext null
val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext null
return@withContext groupRecord.requireV2GroupProperties().memberLabel(aci)
groupRecord.requireV2GroupProperties().memberLabel(aci)?.sanitized()
}
/**
@@ -87,7 +87,7 @@ class MemberLabelRepository private constructor(
buildMap {
recipients.forEach { recipient ->
val aci = recipient.serviceId.orNull() as? ServiceId.ACI
labelsByAci[aci]?.let { label -> put(recipient.id, label) }
labelsByAci[aci]?.let { label -> put(recipient.id, label.sanitized()) }
}
}
}
@@ -122,6 +122,12 @@ class MemberLabelRepository private constructor(
throw IllegalStateException("Set member label not allowed due to remote config.")
}
GroupManager.updateMemberLabel(context, groupId, label.text, label.emoji.orEmpty())
val sanitizedLabel = label.sanitized()
GroupManager.updateMemberLabel(context, groupId, sanitizedLabel.text, sanitizedLabel.emoji.orEmpty())
}
}
private fun MemberLabel.sanitized(): MemberLabel = this.copy(
emoji = MemberLabel.sanitizeEmoji(this.emoji),
text = MemberLabel.sanitizeLabelText(this.text)
)

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.util.StringUtil
import org.signal.core.util.concurrent.SignalDispatchers
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.groups.GroupId
@@ -19,9 +20,6 @@ import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveStat
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
private const val MIN_LABEL_TEXT_LENGTH = 1
private const val MAX_LABEL_TEXT_LENGTH = 24
class MemberLabelViewModel(
private val memberLabelRepo: MemberLabelRepository = MemberLabelRepository.instance,
private val groupId: GroupId.V2,
@@ -65,11 +63,11 @@ class MemberLabelViewModel(
}
fun onLabelTextChanged(text: String) {
val sanitizedText = text.take(MAX_LABEL_TEXT_LENGTH)
val truncatedText = MemberLabel.truncateLabelText(text)
internalUiState.update {
it.copy(
labelText = sanitizedText,
hasChanges = hasChanges(labelEmoji = it.labelEmoji, labelText = sanitizedText)
labelText = truncatedText,
hasChanges = hasChanges(labelEmoji = it.labelEmoji, labelText = truncatedText)
)
}
}
@@ -85,7 +83,7 @@ class MemberLabelViewModel(
}
private fun hasChanges(labelEmoji: String, labelText: String): Boolean {
return labelEmoji != originalLabelEmoji || labelText != originalLabelText
return labelEmoji != originalLabelEmoji || MemberLabel.sanitizeLabelText(labelText) != originalLabelText
}
fun save() {
@@ -128,13 +126,16 @@ data class MemberLabelUiState(
val hasChanges: Boolean = false,
val saveState: SaveState? = null
) {
val sanitizedLabelText: String get() = MemberLabel.sanitizeLabelText(labelText)
val remainingCharacters: Int
get() = MAX_LABEL_TEXT_LENGTH - labelText.length
get() = MemberLabel.MAX_LABEL_GRAPHEMES - StringUtil.getGraphemeCount(sanitizedLabelText)
val isSaveEnabled: Boolean
get() {
val isCleared = labelText.isEmpty() && labelEmoji.isEmpty()
val hasValidLabel = labelText.length in MIN_LABEL_TEXT_LENGTH..MAX_LABEL_TEXT_LENGTH
val isCleared = sanitizedLabelText.isEmpty() && labelEmoji.isEmpty()
val graphemeCount = StringUtil.getGraphemeCount(sanitizedLabelText)
val hasValidLabel = graphemeCount in MemberLabel.MIN_LABEL_GRAPHEMES..MemberLabel.MAX_LABEL_GRAPHEMES
return hasChanges && (hasValidLabel || isCleared) && saveState != SaveState.InProgress
}

View File

@@ -48,7 +48,7 @@ fun SenderNameWithLabel(
labelSlot = { label ->
MemberLabelPill(
emoji = label.emoji,
text = label.text,
text = label.displayText,
tintColor = senderColor,
modifier = defaultLabelModifier,
textStyle = defaultLabelTextStyle()
@@ -78,7 +78,7 @@ fun SenderNameWithLabel(
labelSlot = { label ->
MemberLabelPill(
emoji = label.emoji,
text = label.text,
text = label.displayText,
textColor = labelTextColor,
backgroundColor = labelBackgroundColor,
modifier = defaultLabelModifier,

View File

@@ -381,7 +381,7 @@ private fun MemberLabelRow(
}
Text(
text = memberLabel.text,
text = memberLabel.displayText,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,