diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index 3e8d776f50..bbb8efdadf 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -33,6 +33,7 @@ android { implementation(libs.vanniktech.blurhash) implementation(projects.features.enterprise.api) implementation(projects.libraries.architecture) + implementation(projects.libraries.core) implementation(projects.libraries.preferences.api) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt index a3cd6f75e3..12b6fe05a3 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt @@ -8,6 +8,8 @@ package io.element.android.libraries.designsystem.components.avatar import androidx.compose.runtime.Immutable +import io.element.android.libraries.core.data.tryOrNull +import java.text.BreakIterator @Immutable data class AvatarData( @@ -27,24 +29,36 @@ data class AvatarData( startIndex++ } - var length = 1 - var first = dn[startIndex] + var next = dn[startIndex] // LEFT-TO-RIGHT MARK - if (dn.length >= 2 && 0x200e == first.code) { + if (dn.length >= 2 && 0x200e == next.code) { startIndex++ - first = dn[startIndex] + next = dn[startIndex] } - // check if it’s the start of a surrogate pair - if (first.code in 0xD800..0xDBFF && dn.length > startIndex + 1) { - val second = dn[startIndex + 1] - if (second.code in 0xDC00..0xDFFF) { - length++ + while (next.isWhitespace()) { + if (dn.length > startIndex + 1) { + startIndex++ + next = dn[startIndex] + } else { + break } } - dn.substring(startIndex, startIndex + length) + val fullCharacterIterator = BreakIterator.getCharacterInstance() + fullCharacterIterator.setText(dn) + val glyphBoundary = tryOrNull { fullCharacterIterator.following(startIndex) } + ?.takeIf { it in startIndex..dn.length } + + when { + // Use the found boundary + glyphBoundary != null -> dn.substring(startIndex, glyphBoundary) + // If no boundary was found, default to the next char if possible + startIndex + 1 < dn.length -> dn.substring(startIndex, startIndex + 1) + // Return a fallback character otherwise + else -> "#" + } } .uppercase() } diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataTest.kt new file mode 100644 index 0000000000..6fe50e995b --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class AvatarDataTest { + @Test + fun `initial with text should get the first char, uppercased`() { + val data = AvatarData("id", "test", null, AvatarSize.InviteSender) + assertThat(data.initial).isEqualTo("T") + } + + @Test + fun `initial with leading whitespace should get the first non-whitespace char, uppercased`() { + val data = AvatarData("id", " test", null, AvatarSize.InviteSender) + assertThat(data.initial).isEqualTo("T") + } + + @Test + fun `initial with long emoji should get the full emoji`() { + val data = AvatarData("id", "\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08 Test", null, AvatarSize.InviteSender) + assertThat(data.initial).isEqualTo("\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08") + } + + @Test + fun `initial with short emoji should get the emoji`() { + val data = AvatarData("id", "✂ Test", null, AvatarSize.InviteSender) + assertThat(data.initial).isEqualTo("✂") + } + + @Test + fun `initial with a single letter should take that letter`() { + val data = AvatarData("id", "T", null, AvatarSize.InviteSender) + assertThat(data.initial).isEqualTo("T") + } +}