Stop rescanning message bodies for emoji only on every recomposition (#7531)

Deciding whether a text message is emoji only compares the formatted body to
the plain one and runs a grapheme scan over the whole message. Nothing was
remembered, so both ran again on every recomposition of every visible message.

Remember the decision against the body and the formatted body. The preview
branch moves into the same computation, so the composable wrapper around
containsOnlyEmojis, which had a single caller and existed only to read
LocalInspectionMode, is no longer needed.
This commit is contained in:
hayyaksi
2026-08-31 13:23:01 +03:00
committed by GitHub
parent 9855224e50
commit c6fe9786a9
3 changed files with 26 additions and 28 deletions
@@ -15,7 +15,9 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
@@ -23,6 +25,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.AN_EMOJI_ONLY_TEXT
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentPreviewParam
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
@@ -47,8 +50,13 @@ fun TimelineItemTextView(
// The View <-> Compose interop is not working well with Compose UI tests (it loops indefinitely), so we skip it in the UI test mode.
if (LocalUiTestMode.current) return
val emojiOnly = content.formattedBody.toString() == content.body &&
content.body.replace(" ", "").containsOnlyEmojis()
val isInPreview = LocalInspectionMode.current
val emojiOnly = remember(content.body, content.formattedBody, isInPreview) {
content.formattedBody.toString() == content.body &&
content.body.replace(" ", "").let { body ->
if (isInPreview) body == AN_EMOJI_ONLY_TEXT else body.containsOnlyEmojis()
}
}
val textStyle = when {
emojiOnly -> ElementTheme.typography.fontHeadingXlRegular
else -> ElementTheme.typography.fontBodyLgRegular
@@ -8,25 +8,15 @@
package io.element.android.features.messages.impl.utils
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalInspectionMode
import com.sigpwned.emoji4j.core.Grapheme.Type.EMOJI
import com.sigpwned.emoji4j.core.Grapheme.Type.PICTOGRAPHIC
import com.sigpwned.emoji4j.core.GraphemeMatchResult
import com.sigpwned.emoji4j.core.GraphemeMatcher
import io.element.android.features.messages.impl.timeline.model.event.AN_EMOJI_ONLY_TEXT
/**
* Returns true if the string consists exclusively of "emoji or pictographic graphemes".
*/
@Composable
fun String.containsOnlyEmojis(): Boolean {
if (LocalInspectionMode.current) return this == AN_EMOJI_ONLY_TEXT
if (isEmpty()) return false
return containsOnlyEmojisInternal()
}
internal fun String.containsOnlyEmojisInternal(): Boolean {
internal fun String.containsOnlyEmojis(): Boolean {
val matcher = GraphemeMatcher(this)
var m: GraphemeMatchResult? = null
var contiguous = true
@@ -16,23 +16,23 @@ class EmojiTest {
@Test
fun validEmojis() {
// Simple single/multiple single-codepoint emojis per string
assertTrue("👍".containsOnlyEmojisInternal())
assertTrue("😀".containsOnlyEmojisInternal())
assertTrue("🙂🙁".containsOnlyEmojisInternal())
assertTrue("👁❤️🍝".containsOnlyEmojisInternal()) // 👁 is a pictographic
assertTrue("👨‍👩‍👦1️⃣🚀👳🏾‍♂️🪩".containsOnlyEmojisInternal())
assertTrue("🌍🌎🌏".containsOnlyEmojisInternal())
assertTrue("👍".containsOnlyEmojis())
assertTrue("😀".containsOnlyEmojis())
assertTrue("🙂🙁".containsOnlyEmojis())
assertTrue("👁❤️🍝".containsOnlyEmojis()) // 👁 is a pictographic
assertTrue("👨‍👩‍👦1️⃣🚀👳🏾‍♂️🪩".containsOnlyEmojis())
assertTrue("🌍🌎🌏".containsOnlyEmojis())
// Awkward multi-codepoint graphemes
assertTrue("🧑‍🧑‍🧒‍🧒".containsOnlyEmojisInternal())
assertTrue("🏴‍☠".containsOnlyEmojisInternal())
assertTrue("👩🏿‍🔧".containsOnlyEmojisInternal())
assertTrue("🧑‍🧑‍🧒‍🧒".containsOnlyEmojis())
assertTrue("🏴‍☠".containsOnlyEmojis())
assertTrue("👩🏿‍🔧".containsOnlyEmojis())
Assert.assertFalse("".containsOnlyEmojisInternal())
Assert.assertFalse(" ".containsOnlyEmojisInternal())
Assert.assertFalse("🙂 🙁".containsOnlyEmojisInternal())
Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojisInternal())
Assert.assertFalse("Hello".containsOnlyEmojisInternal())
Assert.assertFalse("Hello 👋".containsOnlyEmojisInternal())
Assert.assertFalse("".containsOnlyEmojis())
Assert.assertFalse(" ".containsOnlyEmojis())
Assert.assertFalse("🙂 🙁".containsOnlyEmojis())
Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojis())
Assert.assertFalse("Hello".containsOnlyEmojis())
Assert.assertFalse("Hello 👋".containsOnlyEmojis())
}
}