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.material3.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview 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.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout 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.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.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentPreviewParam import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentPreviewParam
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent 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. // 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 if (LocalUiTestMode.current) return
val emojiOnly = content.formattedBody.toString() == content.body && val isInPreview = LocalInspectionMode.current
content.body.replace(" ", "").containsOnlyEmojis() 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 { val textStyle = when {
emojiOnly -> ElementTheme.typography.fontHeadingXlRegular emojiOnly -> ElementTheme.typography.fontHeadingXlRegular
else -> ElementTheme.typography.fontBodyLgRegular else -> ElementTheme.typography.fontBodyLgRegular
@@ -8,25 +8,15 @@
package io.element.android.features.messages.impl.utils 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.EMOJI
import com.sigpwned.emoji4j.core.Grapheme.Type.PICTOGRAPHIC import com.sigpwned.emoji4j.core.Grapheme.Type.PICTOGRAPHIC
import com.sigpwned.emoji4j.core.GraphemeMatchResult import com.sigpwned.emoji4j.core.GraphemeMatchResult
import com.sigpwned.emoji4j.core.GraphemeMatcher 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". * Returns true if the string consists exclusively of "emoji or pictographic graphemes".
*/ */
@Composable internal fun String.containsOnlyEmojis(): Boolean {
fun String.containsOnlyEmojis(): Boolean {
if (LocalInspectionMode.current) return this == AN_EMOJI_ONLY_TEXT
if (isEmpty()) return false
return containsOnlyEmojisInternal()
}
internal fun String.containsOnlyEmojisInternal(): Boolean {
val matcher = GraphemeMatcher(this) val matcher = GraphemeMatcher(this)
var m: GraphemeMatchResult? = null var m: GraphemeMatchResult? = null
var contiguous = true var contiguous = true
@@ -16,23 +16,23 @@ class EmojiTest {
@Test @Test
fun validEmojis() { fun validEmojis() {
// Simple single/multiple single-codepoint emojis per string // Simple single/multiple single-codepoint emojis per string
assertTrue("👍".containsOnlyEmojisInternal()) assertTrue("👍".containsOnlyEmojis())
assertTrue("😀".containsOnlyEmojisInternal()) assertTrue("😀".containsOnlyEmojis())
assertTrue("🙂🙁".containsOnlyEmojisInternal()) assertTrue("🙂🙁".containsOnlyEmojis())
assertTrue("👁❤️🍝".containsOnlyEmojisInternal()) // 👁 is a pictographic assertTrue("👁❤️🍝".containsOnlyEmojis()) // 👁 is a pictographic
assertTrue("👨‍👩‍👦1️⃣🚀👳🏾‍♂️🪩".containsOnlyEmojisInternal()) assertTrue("👨‍👩‍👦1️⃣🚀👳🏾‍♂️🪩".containsOnlyEmojis())
assertTrue("🌍🌎🌏".containsOnlyEmojisInternal()) assertTrue("🌍🌎🌏".containsOnlyEmojis())
// Awkward multi-codepoint graphemes // Awkward multi-codepoint graphemes
assertTrue("🧑‍🧑‍🧒‍🧒".containsOnlyEmojisInternal()) assertTrue("🧑‍🧑‍🧒‍🧒".containsOnlyEmojis())
assertTrue("🏴‍☠".containsOnlyEmojisInternal()) assertTrue("🏴‍☠".containsOnlyEmojis())
assertTrue("👩🏿‍🔧".containsOnlyEmojisInternal()) assertTrue("👩🏿‍🔧".containsOnlyEmojis())
Assert.assertFalse("".containsOnlyEmojisInternal()) Assert.assertFalse("".containsOnlyEmojis())
Assert.assertFalse(" ".containsOnlyEmojisInternal()) Assert.assertFalse(" ".containsOnlyEmojis())
Assert.assertFalse("🙂 🙁".containsOnlyEmojisInternal()) Assert.assertFalse("🙂 🙁".containsOnlyEmojis())
Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojisInternal()) Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojis())
Assert.assertFalse("Hello".containsOnlyEmojisInternal()) Assert.assertFalse("Hello".containsOnlyEmojis())
Assert.assertFalse("Hello 👋".containsOnlyEmojisInternal()) Assert.assertFalse("Hello 👋".containsOnlyEmojis())
} }
} }