Merge branch 'develop' into feature/fga/timeline-multi-forward

This commit is contained in:
ganfra
2026-09-29 17:15:40 +02:00
1583 changed files with 15749 additions and 7431 deletions
@@ -34,6 +34,7 @@ class AccountSelectPresenter(
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
}
.toImmutableList()
@@ -44,6 +44,7 @@ class AccountSelectPresenterTest {
sessionId = A_SESSION_ID_2.value,
userDisplayName = "Bob",
userAvatarUrl = "avatarUrl",
userAvatarData = "avatarData",
),
)
)
@@ -66,6 +67,7 @@ class AccountSelectPresenterTest {
userId = A_SESSION_ID_2,
displayName = "Bob",
avatarUrl = "avatarUrl",
avatarThumbnail = "avatarData",
)
)
}
+25 -4
View File
@@ -10,7 +10,7 @@ import extension.testCommonDependencies
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.roborazzi)
alias(libs.plugins.paparazzi)
}
android {
@@ -21,9 +21,30 @@ android {
}
}
// Paparazzi's layoutlib runtime conflicts with Robolectric, so during Paparazzi record/verify runs
// only execute the screenshot tests (which use Paparazzi) and leave the Robolectric-based unit tests
// out. On plain unit test runs the screenshot package is excluded in the root build.gradle.kts.
tasks.withType(Test::class).configureEach {
val isScreenshotTest = gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) }
if (isScreenshotTest) {
include("**/screenshot/**")
}
}
dependencies {
testCommonDependencies(libs)
testImplementation(libs.test.roborazzi)
testImplementation(libs.test.roborazzi.compose)
testImplementation(libs.test.roborazzi.junit)
// Paparazzi 1.3.2 workaround (see https://github.com/cashapp/paparazzi/blob/master/CHANGELOG.md#132---2024-01-13)
constraints.add("testImplementation", "com.google.guava:guava") {
attributes {
attribute(
TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE,
objects.named(TargetJvmEnvironment::class.java, TargetJvmEnvironment.STANDARD_JVM)
)
}
because(
"LayoutLib and sdk-common depend on Guava's -jre published variant." +
"See https://github.com/cashapp/paparazzi/issues/906."
)
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,6 +11,7 @@ package io.element.android.compound.previews
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -27,7 +28,7 @@ import kotlinx.collections.immutable.persistentMapOf
@Preview(heightDp = 2000)
@Composable
internal fun CompoundSemanticColorsLight() = ElementTheme {
Surface {
Surface(Modifier.width(300.dp)) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
@@ -48,7 +49,7 @@ internal fun CompoundSemanticColorsLight() = ElementTheme {
internal fun CompoundSemanticColorsLightHc() = ElementTheme(
compoundDark = compoundColorsHcDark,
) {
Surface {
Surface(Modifier.width(300.dp)) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
@@ -67,7 +68,7 @@ internal fun CompoundSemanticColorsLightHc() = ElementTheme(
@Preview(heightDp = 2000)
@Composable
internal fun CompoundSemanticColorsDark() = ElementTheme(theme = Theme.Dark) {
Surface {
Surface(Modifier.width(300.dp)) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
@@ -89,7 +90,7 @@ internal fun CompoundSemanticColorsDarkHc() = ElementTheme(
theme = Theme.Dark,
compoundDark = compoundColorsHcDark,
) {
Surface {
Surface(Modifier.width(300.dp)) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
@@ -8,24 +8,22 @@
package io.element.android.compound.screenshot
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.screenshot.utils.createPaparazziRule
import io.element.android.compound.theme.AvatarColorsPreviewDark
import io.element.android.compound.theme.AvatarColorsPreviewLight
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Rule
import org.junit.Test
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class AvatarColorsTest : RobolectricTest() {
class AvatarColorsTest {
@get:Rule
val paparazzi = createPaparazziRule()
@Test
@Config(sdk = [35], qualifiers = "xxhdpi")
fun screenshots() {
captureRoboImage(file = screenshotFile("Avatar Colors - Light.png")) {
paparazzi.snapshot(name = "Avatar Colors - Light") {
AvatarColorsPreviewLight()
}
captureRoboImage(file = screenshotFile("Avatar Colors - Dark.png")) {
paparazzi.snapshot(name = "Avatar Colors - Dark") {
AvatarColorsPreviewDark()
}
}
@@ -11,36 +11,34 @@ package io.element.android.compound.screenshot
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.IconsCompoundPreviewDark
import io.element.android.compound.previews.IconsCompoundPreviewLight
import io.element.android.compound.previews.IconsCompoundPreviewRtl
import io.element.android.compound.previews.IconsPreview
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.screenshot.utils.createPaparazziRule
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.theme.Theme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.toImmutableList
import org.junit.Rule
import org.junit.Test
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class CompoundIconTest : RobolectricTest() {
class CompoundIconTest {
@get:Rule
val paparazzi = createPaparazziRule()
@Test
@Config(sdk = [35], qualifiers = "w1024dp-h2048dp")
fun screenshots() {
captureRoboImage(file = screenshotFile("Compound Icons - Light.png")) {
paparazzi.snapshot(name = "Compound Icons - Light") {
IconsCompoundPreviewLight()
}
captureRoboImage(file = screenshotFile("Compound Icons - Rtl.png")) {
paparazzi.snapshot(name = "Compound Icons - Rtl") {
IconsCompoundPreviewRtl()
}
captureRoboImage(file = screenshotFile("Compound Icons - Dark.png")) {
paparazzi.snapshot(name = "Compound Icons - Dark") {
IconsCompoundPreviewDark()
}
captureRoboImage(file = screenshotFile("Compound Vector Icons - Light.png")) {
paparazzi.snapshot(name = "Compound Vector Icons - Light") {
val content: List<@Composable ColumnScope.() -> Unit> = CompoundIcons.all.map {
@Composable { Icon(imageVector = it, contentDescription = null) }
}
@@ -51,7 +49,7 @@ class CompoundIconTest : RobolectricTest() {
)
}
}
captureRoboImage(file = screenshotFile("Compound Vector Icons - Dark.png")) {
paparazzi.snapshot(name = "Compound Vector Icons - Dark") {
val content: List<@Composable ColumnScope.() -> Unit> = CompoundIcons.all.map {
@Composable { Icon(imageVector = it, contentDescription = null) }
}
@@ -8,20 +8,18 @@
package io.element.android.compound.screenshot
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.CompoundTypographyPreview
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.compound.screenshot.utils.createPaparazziRule
import org.junit.Rule
import org.junit.Test
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class CompoundTypographyTest : RobolectricTest() {
class CompoundTypographyTest {
@get:Rule
val paparazzi = createPaparazziRule()
@Test
@Config(sdk = [35], qualifiers = "h2048dp-xxhdpi")
fun screenshots() {
captureRoboImage(file = screenshotFile("Compound Typography.png")) {
paparazzi.snapshot(name = "Compound Typography") {
CompoundTypographyPreview()
}
}
@@ -18,22 +18,21 @@ import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.screenshot.utils.createPaparazziRule
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Rule
import org.junit.Test
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ForcedDarkElementThemeTest : RobolectricTest() {
class ForcedDarkElementThemeTest {
// The preview uses `fillMaxSize` to fill the inner Surface, so it needs a bounded frame.
@get:Rule
val paparazzi = createPaparazziRule(widthDp = 420, heightDp = 640)
@Test
@Config(sdk = [35], qualifiers = "xxhdpi")
fun screenshots() {
captureRoboImage(file = screenshotFile("ForcedDarkElementTheme.png")) {
paparazzi.snapshot(name = "ForcedDarkElementTheme") {
ElementTheme {
Surface {
Column(
@@ -12,32 +12,31 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.ColorPreview
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.screenshot.utils.createPaparazziRule
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.theme.LinkColor
import io.element.android.compound.theme.SnackBarLabelColorDark
import io.element.android.compound.theme.SnackBarLabelColorLight
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Rule
import org.junit.Test
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class LegacyColorsTest : RobolectricTest() {
class LegacyColorsTest {
@get:Rule
val paparazzi = createPaparazziRule()
@Test
@Config(sdk = [35], qualifiers = "xxhdpi")
fun screenshots() {
captureRoboImage(file = screenshotFile("Legacy Colors.png")) {
paparazzi.snapshot(name = "Legacy Colors") {
ElementTheme {
Surface {
Surface(Modifier.width(300.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Legacy Colors")
Spacer(modifier = Modifier.height(10.dp))
@@ -12,32 +12,31 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.screenshot.utils.createPaparazziRule
import io.element.android.compound.theme.ColorsSchemeDarkHcPreview
import io.element.android.compound.theme.ColorsSchemeDarkPreview
import io.element.android.compound.theme.ColorsSchemeLightHcPreview
import io.element.android.compound.theme.ColorsSchemeLightPreview
import io.element.android.compound.theme.ElementTheme
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Rule
import org.junit.Test
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class MaterialColorSchemeTest : RobolectricTest() {
class MaterialColorSchemeTest {
@get:Rule
val paparazzi = createPaparazziRule()
@Test
@Config(sdk = [35], qualifiers = "h2048dp-xhdpi")
fun screenshots() {
captureRoboImage(file = screenshotFile("Material3 Colors - Light.png")) {
paparazzi.snapshot(name = "Material3 Colors - Light") {
ElementTheme {
Surface {
Surface(Modifier.width(300.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "M3 Light colors",
@@ -49,9 +48,9 @@ class MaterialColorSchemeTest : RobolectricTest() {
}
}
}
captureRoboImage(file = screenshotFile("Material3 Colors - Light HC.png")) {
paparazzi.snapshot(name = "Material3 Colors - Light HC") {
ElementTheme {
Surface {
Surface(Modifier.width(300.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "M3 Light HC colors",
@@ -63,9 +62,9 @@ class MaterialColorSchemeTest : RobolectricTest() {
}
}
}
captureRoboImage(file = screenshotFile("Material3 Colors - Dark.png")) {
paparazzi.snapshot(name = "Material3 Colors - Dark") {
ElementTheme {
Surface {
Surface(Modifier.width(300.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "M3 Dark colors",
@@ -77,9 +76,9 @@ class MaterialColorSchemeTest : RobolectricTest() {
}
}
}
captureRoboImage(file = screenshotFile("Material3 Colors - Dark HC.png")) {
paparazzi.snapshot(name = "Material3 Colors - Dark HC") {
ElementTheme {
Surface {
Surface(Modifier.width(300.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "M3 Dark HC colors",
@@ -8,20 +8,19 @@
package io.element.android.compound.screenshot
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.screenshot.utils.createPaparazziRule
import io.element.android.compound.theme.MaterialTextPreview
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Rule
import org.junit.Test
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class MaterialTextTest : RobolectricTest() {
class MaterialTextTest {
// MaterialTextPreview relies on `weight`/`fillMaxSize`, so it needs a bounded frame to lay out.
@get:Rule
val paparazzi = createPaparazziRule(widthDp = 420, heightDp = 1200)
@Test
@Config(sdk = [35], qualifiers = "w480dp-h1200dp-xxhdpi")
fun screenshots() {
captureRoboImage(file = screenshotFile("MaterialText Colors.png")) {
paparazzi.snapshot(name = "MaterialText Colors") {
MaterialTextPreview()
}
}
@@ -8,20 +8,18 @@
package io.element.android.compound.screenshot
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.TypographyPreview
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.compound.screenshot.utils.createPaparazziRule
import org.junit.Rule
import org.junit.Test
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class MaterialTypographyTest : RobolectricTest() {
class MaterialTypographyTest {
@get:Rule
val paparazzi = createPaparazziRule()
@Test
@Config(sdk = [35], qualifiers = "h2048dp-xxhdpi")
fun screenshots() {
captureRoboImage(file = screenshotFile("Material Typography.png")) {
paparazzi.snapshot(name = "Material Typography") {
TypographyPreview()
}
}
@@ -19,22 +19,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.ColorsSchemePreview
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.screenshot.utils.createPaparazziRule
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.theme.Theme
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Rule
import org.junit.Test
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class MaterialYouThemeTest : RobolectricTest() {
class MaterialYouThemeTest {
@get:Rule
val paparazzi = createPaparazziRule()
@Test
@Config(sdk = [35], qualifiers = "h2048dp-xhdpi")
fun screenshots() {
captureRoboImage(file = screenshotFile("MaterialYou Theme - Light.png")) {
paparazzi.snapshot(name = "MaterialYou Theme - Light") {
ElementTheme(dynamicColor = true) {
Surface {
Column(
@@ -49,7 +47,7 @@ class MaterialYouThemeTest : RobolectricTest() {
}
}
}
captureRoboImage(file = screenshotFile("MaterialYou Theme - Dark.png")) {
paparazzi.snapshot(name = "MaterialYou Theme - Dark") {
ElementTheme(dynamicColor = true, theme = Theme.Dark) {
Surface {
Column(
@@ -8,35 +8,33 @@
package io.element.android.compound.screenshot
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.CompoundSemanticColorsDark
import io.element.android.compound.previews.CompoundSemanticColorsDarkHc
import io.element.android.compound.previews.CompoundSemanticColorsLight
import io.element.android.compound.previews.CompoundSemanticColorsLightHc
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.compound.screenshot.utils.createPaparazziRule
import org.junit.Rule
import org.junit.Test
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SemanticColorsTest : RobolectricTest() {
@Config(sdk = [35], qualifiers = "h2000dp-xhdpi")
class SemanticColorsTest {
@get:Rule
val paparazzi = createPaparazziRule()
@Test
fun screenshots() {
captureRoboImage(file = screenshotFile("Compound Semantic Colors - Light.png")) {
paparazzi.snapshot(name = "Compound Semantic Colors - Light") {
CompoundSemanticColorsLight()
}
captureRoboImage(file = screenshotFile("Compound Semantic Colors - Light HC.png")) {
paparazzi.snapshot(name = "Compound Semantic Colors - Light HC") {
CompoundSemanticColorsLightHc()
}
captureRoboImage(file = screenshotFile("Compound Semantic Colors - Dark.png")) {
paparazzi.snapshot(name = "Compound Semantic Colors - Dark") {
CompoundSemanticColorsDark()
}
captureRoboImage(file = screenshotFile("Compound Semantic Colors - Dark HC.png")) {
paparazzi.snapshot(name = "Compound Semantic Colors - Dark HC") {
CompoundSemanticColorsDarkHc()
}
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.compound.screenshot.utils
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import com.android.ide.common.rendering.api.SessionParams.RenderingMode
import com.android.resources.Density
// Screenshots are captured at xxhdpi so the icon grids stay sharp. This is the density scale factor
// (480dpi / 160dpi), used to convert the dp bounds below into the pixel values [DeviceConfig] expects.
private const val DENSITY_SCALE = 2
// Upper bounds for self-sizing screenshots, in dp. Large enough to fit the tallest and widest
// Compound preview; `RenderingMode.SHRINK` then crops each image down to its actual content.
private const val MAX_WIDTH_DP = 2048
private const val MAX_HEIGHT_DP = 4800
/**
* Creates a [Paparazzi] rule for the Compound screenshot tests.
*
* By default each screenshot sizes itself to its content ([RenderingMode.SHRINK]) instead of being
* rendered at a fixed device frame size, and the [DeviceConfig] only acts as an upper bound.
*
* Pass [widthDp] and [heightDp] for previews that rely on `fillMaxSize`/`weight` and therefore need
* a bounded frame to lay out correctly: they are then rendered at that fixed size instead.
*
* [useDeviceResolution] is enabled so screenshots are captured at full device resolution. Without
* it, Paparazzi downscales every image to a 1000px max dimension, which makes large previews (such
* as the icon grids) look pixelated.
*/
fun createPaparazziRule(
widthDp: Int? = null,
heightDp: Int? = null,
): Paparazzi {
val selfSizing = widthDp == null || heightDp == null
return Paparazzi(
deviceConfig = DeviceConfig(
screenWidth = (widthDp ?: MAX_WIDTH_DP) * DENSITY_SCALE,
screenHeight = (heightDp ?: MAX_HEIGHT_DP) * DENSITY_SCALE,
xdpi = 480,
ydpi = 480,
density = Density.XXHIGH,
softButtons = false,
),
renderingMode = if (selfSizing) RenderingMode.SHRINK else RenderingMode.NORMAL,
supportsRtl = true,
useDeviceResolution = true,
maxPercentDifference = 0.01,
)
}
@@ -1,19 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.compound.screenshot.utils
import java.io.File
/**
* Returns a [File] object for a screenshot with the given [filename].
* This is to ensure we have a consistent location for all screenshots.
*/
fun screenshotFile(filename: String): File {
return File("screenshots", filename)
}
+7 -1
View File
@@ -19,6 +19,12 @@ android {
buildFeatures {
buildConfig = true
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
dependencies {
@@ -36,5 +42,5 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
testCommonDependencies(libs)
testCommonDependencies(libs, true)
}
@@ -35,8 +35,8 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@Composable
fun RoomPreviewAliasAtom(
alias: String,
fun CopiableTextAtom(
text: String,
modifier: Modifier = Modifier,
copiable: Boolean = true
) {
@@ -46,7 +46,7 @@ fun RoomPreviewAliasAtom(
modifier = modifier
.clickable(enabled = copiable) {
coroutineScope.launch {
val clipData = ClipData.newPlainText(alias, alias)
val clipData = ClipData.newPlainText(text, text)
clipboard.setClipEntry(clipData.toClipEntry())
}
},
@@ -55,7 +55,7 @@ fun RoomPreviewAliasAtom(
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = alias,
text = text,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
maxLines = 1,
@@ -75,9 +75,9 @@ fun RoomPreviewAliasAtom(
@PreviewsDayNight
@Composable
internal fun RoomPreviewAliasAtomPreview() = ElementPreview {
RoomPreviewAliasAtom(
alias = "#room-alias:matrix.org",
internal fun CopiableTextAtomPreview() = ElementPreview {
CopiableTextAtom(
text = "#room-alias:matrix.org",
copiable = true
)
}
@@ -8,39 +8,43 @@
package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
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.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text
private const val MAX_COUNT = 99
private const val MAX_COUNT_STRING = "+$MAX_COUNT"
private const val MAX_COUNT_STRING = "$MAX_COUNT+"
/**
* A counter atom that displays a number in a circle.
* Figma link : https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2805-2649&m=dev
*
* @param count The number to display. If the number is greater than [MAX_COUNT], the counter will display [MAX_COUNT_STRING].
* If the number is less than 1, the counter will not be displayed.
* If the number is less than 1, just a dot will be displayed instead.
* @param modifier The modifier to apply to this layout.
* @param containerColor The background color of the counter. When null, uses [isCritical] to pick a default.
* @param contentColor The text color inside the counter. When null, uses [textOnSolidPrimary].
* @param contentColor The text color inside the counter. When null, uses [SemanticColors.textOnSolidPrimary].
* @param contentPadding The padding to apply to the text inside the counter.
* @param textStyle The style to apply to the text inside the counter.
* @param dotSize The size of the dot when [count] is less than 1. It defaults to `12.0.dp`.
* @param isCritical If true, the counter will use a critical color scheme, otherwise it will use an accent color scheme.
* Only used when [containerColor] is null.
*/
@@ -50,41 +54,51 @@ fun CounterAtom(
modifier: Modifier = Modifier,
containerColor: Color? = null,
contentColor: Color? = null,
contentPadding: PaddingValues = PaddingValues.Zero,
textStyle: TextStyle = CounterAtomDefaults.textStyle,
dotSize: Dp = 12.0.dp,
isCritical: Boolean = false,
) {
if (count < 1) return
val countAsText = when (count) {
in 0..MAX_COUNT -> count.toString()
in Int.MIN_VALUE..0 -> null
in 1..MAX_COUNT -> count.toString()
else -> MAX_COUNT_STRING
}
val textMeasurer = rememberTextMeasurer()
// Measure the maximum count string size
val textLayoutResult = textMeasurer.measure(
text = MAX_COUNT_STRING,
style = textStyle
)
val textSize = textLayoutResult.size
val squareSize = maxOf(textSize.width, textSize.height)
Box(
modifier = modifier
.size(squareSize.toDp() + 1.dp)
.clip(CircleShape)
.background(
containerColor ?: if (isCritical) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconAccentPrimary
}
)
) {
Text(
modifier = Modifier.align(Alignment.Center),
val minSingleDigitSize = if (countAsText?.length == 1) {
val textMeasurer = rememberTextMeasurer()
// Measure the maximum count string size
val textLayoutResult = textMeasurer.measure(
text = countAsText,
style = textStyle,
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
style = textStyle
)
val textSize = textLayoutResult.size
maxOf(textSize.width, textSize.height)
} else {
0
}
androidx.compose.material3.Badge(
modifier = modifier.then(
if (countAsText == null) Modifier.sizeIn(minWidth = dotSize, minHeight = dotSize) else Modifier
),
containerColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary,
contentColor = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
content = countAsText?.let {
@Composable {
Text(
modifier = Modifier.sizeIn(
minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp),
minHeight = minSingleDigitSize.toDp()
).padding(contentPadding),
text = countAsText,
style = textStyle,
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
textAlign = TextAlign.Center,
)
}
}
)
}
object CounterAtomDefaults {
@@ -95,7 +109,7 @@ object CounterAtomDefaults {
@PreviewsDayNight
@Composable
internal fun CounterAtomPreview() = ElementPreview {
Column(verticalArrangement = spacedBy(2.dp)) {
Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
CounterAtom(count = 0)
CounterAtom(count = 4)
CounterAtom(count = 99)
@@ -9,15 +9,16 @@
package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -37,35 +38,36 @@ fun UnreadIndicatorAtom(
isVisible: Boolean = true,
contentDescription: String? = null,
border: BorderStroke? = null,
contentPadding: PaddingValues = PaddingValues.Zero,
) {
when {
!isVisible -> Spacer(modifier = modifier.size(size))
count != null && count >= 1 -> CounterAtom(
count = count.toInt(),
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
containerColor = color,
contentColor = ElementTheme.colors.bgCanvasDefault,
textStyle = ElementTheme.typography.fontBodySmMedium,
)
else -> Box(
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.size(size)
.clip(CircleShape)
.background(color)
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
)
else -> count?.let {
CounterAtom(
count = count.toInt(),
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 50)) else Modifier),
containerColor = color,
contentColor = ElementTheme.colors.bgCanvasDefault,
textStyle = ElementTheme.typography.fontBodySmMedium,
contentPadding = contentPadding,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun UnreadIndicatorAtomPreview() = ElementPreview {
UnreadIndicatorAtom()
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
UnreadIndicatorAtom(count = null)
UnreadIndicatorAtom(count = 0)
UnreadIndicatorAtom(count = 1)
UnreadIndicatorAtom(count = 10)
UnreadIndicatorAtom(count = 99)
UnreadIndicatorAtom(count = 999)
}
}
@@ -32,8 +32,8 @@ import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.text.util.LinkifyCompat
import io.element.android.compound.theme.LinkColor
import io.element.android.libraries.androidutils.text.LinkifyHelper
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.Text
@@ -142,7 +142,7 @@ fun ClickableLinkText(
fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
val original = this
val spannable = SpannableString.valueOf(this.text)
LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
LinkifyHelper.linkify(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java)
return buildAnnotatedString {
@@ -150,7 +150,9 @@ fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
for (span in spans) {
val start = spannable.getSpanStart(span)
val end = spannable.getSpanEnd(span)
if (original.getLinkAnnotations(start, end).isEmpty() && original.getStringAnnotations("URL", start, end).isEmpty()) {
if (original.getLinkAnnotations(start, end).isEmpty() &&
original.getStringAnnotations(LINK_TAG, start, end).isEmpty() &&
original.getStringAnnotations("inline_code", start, end).isEmpty()) {
// Prevent linkifying domains in user or room handles (@user:domain.com, #room:domain.com)
if (start > 0 && !spannable[start - 1].isWhitespace()) continue
@@ -18,6 +18,8 @@ data class AvatarData(
val name: String?,
val url: String? = null,
val size: AvatarSize,
/** Base64 encoded thumbnail of [url], stored locally, used as a fallback when the avatar cannot be loaded. */
val thumbnail: String? = null,
) {
val initialLetter by lazy {
// For roomIds, use "#" as initial
@@ -16,9 +16,11 @@ fun anAvatarData(
name: String? = USER_NAME_ALICE,
url: String? = null,
size: AvatarSize = AvatarSize.RoomListItem,
thumbnail: String? = null,
) = AvatarData(
id = id,
name = name,
url = url,
size = size,
thumbnail = thumbnail,
)
@@ -82,6 +82,7 @@ enum class AvatarSize(val dp: Dp) {
LeaveSpaceRoom(32.dp),
SelectParentSpace(32.dp),
AccountItem(32.dp),
OtherAccountItem(20.dp),
LocationPin(32.dp),
ActiveCallItem(20.dp)
}
@@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
@@ -21,8 +22,10 @@ import androidx.compose.ui.unit.Dp
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import timber.log.Timber
import kotlin.io.encoding.Base64
@Composable
internal fun ImageAvatar(
@@ -33,6 +36,9 @@ internal fun ImageAvatar(
contentDescription: String? = null,
) {
val size = forcedAvatarSize ?: avatarData.size.dp
val thumbnailBytes = remember(avatarData.thumbnail) {
avatarData.thumbnail?.let { runCatchingExceptions { Base64.decode(it) }.getOrNull() }
}
SubcomposeAsyncImage(
model = avatarData,
contentDescription = contentDescription,
@@ -51,15 +57,17 @@ internal fun ImageAvatar(
"Error loading avatar $state\n${state.result}"
)
}
InitialLetterAvatar(
ThumbnailOrInitialLetterAvatar(
avatarData = avatarData,
thumbnailBytes = thumbnailBytes,
avatarShape = avatarShape,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
}
else -> InitialLetterAvatar(
else -> ThumbnailOrInitialLetterAvatar(
avatarData = avatarData,
thumbnailBytes = thumbnailBytes,
avatarShape = avatarShape,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
@@ -67,3 +75,44 @@ internal fun ImageAvatar(
}
}
}
/**
* Render the locally stored thumbnail of the avatar if any, else the initial letter.
*/
@Composable
private fun ThumbnailOrInitialLetterAvatar(
avatarData: AvatarData,
thumbnailBytes: ByteArray?,
avatarShape: Shape,
forcedAvatarSize: Dp?,
contentDescription: String?,
) {
if (thumbnailBytes == null) {
InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
} else {
SubcomposeAsyncImage(
model = thumbnailBytes,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(forcedAvatarSize ?: avatarData.size.dp)
.clip(avatarShape)
) {
val collectedState by painter.state.collectAsState()
when (collectedState) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
else -> InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
}
}
}
}
@@ -47,6 +47,7 @@ fun TextFieldDialog(
maxLines: Int = minLines,
content: String? = null,
label: String? = null,
supportingText: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
submitText: String = stringResource(CommonStrings.action_ok),
destructiveSubmit: Boolean = false,
@@ -84,6 +85,7 @@ fun TextFieldDialog(
TextFieldListItem(
placeholder = placeholder.orEmpty(),
label = label,
supportingText = supportingText,
text = textFieldContents,
onTextChange = {
error = if (!validation(it.text)) onValidationErrorMessage else null
@@ -57,6 +57,7 @@ fun TextFieldListItem(
minLines: Int = 1,
maxLines: Int = minLines,
label: String? = null,
supportingText: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
@@ -66,7 +67,7 @@ fun TextFieldListItem(
placeholder = placeholder,
label = label,
validity = if (error != null) TextFieldValidity.Invalid else TextFieldValidity.None,
supportingText = error,
supportingText = error ?: supportingText,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
maxLines = maxLines,
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2026 Element Creations 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.media
import android.view.MotionEvent
/**
* Interprets a waveform pointer event and reports whether it was consumed.
*
* @param action [MotionEvent] action, such as [MotionEvent.ACTION_DOWN].
* @param x Horizontal position of the pointer, in pixels, relative to the waveform canvas.
* @param waveformWidthPx Width of the drawn waveform, in pixels. Touches past this are ignored.
* @param currentSeekProgress In-progress drag progress (`0` to `1`), or `null` if no drag is active.
* @param onDisallowParentIntercept Called with `true` when a drag starts, and `false` when it ends,
* so a parent scroller does not steal the gesture.
* @param onUpdateSeekProgress Called with the current drag progress, or `null` when the drag ends.
* @param onCommitSeek Called with the final progress when the user lifts their finger after a drag.
* @return `true` when the event was consumed by the waveform.
*/
internal fun handleWaveformPointerEvent(
action: Int,
x: Float,
waveformWidthPx: Float,
currentSeekProgress: Float?,
onDisallowParentIntercept: (Boolean) -> Unit,
onUpdateSeekProgress: (Float?) -> Unit,
onCommitSeek: (Float) -> Unit,
): Boolean {
return when (action) {
MotionEvent.ACTION_DOWN -> {
val progress = seekProgressFromPointerX(x, waveformWidthPx)
if (progress != null) {
onDisallowParentIntercept(true)
onUpdateSeekProgress(progress)
true
} else {
false
}
}
MotionEvent.ACTION_MOVE -> {
val progress = seekProgressFromPointerX(x, waveformWidthPx)
if (progress != null) {
onUpdateSeekProgress(progress)
}
true
}
MotionEvent.ACTION_UP -> {
onDisallowParentIntercept(false)
currentSeekProgress?.let(onCommitSeek)
onUpdateSeekProgress(null)
true
}
MotionEvent.ACTION_CANCEL -> {
onDisallowParentIntercept(false)
onUpdateSeekProgress(null)
true
}
else -> false
}
}
internal fun seekProgressFromPointerX(x: Float, waveformWidthPx: Float): Float? {
if (x in 0F..waveformWidthPx) {
return x / waveformWidthPx
}
return null
}
@@ -0,0 +1,159 @@
/*
* Copyright (c) 2026 Element Creations 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.media
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* Maximum gap, in wall-clock milliseconds, between two progress values that we still treat as
* "the same place" while audio is playing.
*
* Player progress is sampled on a timer, while the waveform cursor is animated every frame.
* A gap smaller than this is expected sampling lag and should be ignored. A larger gap means a
* real jump (seek acknowledged, pause, or the animation has drifted) and the cursor should snap.
*
* The comparison uses wall-clock time, so faster playback makes the same progress-fraction
* delta count as a smaller time gap.
*/
internal const val PLAYING_SYNC_THRESHOLD_MS = 200L
/**
* When media duration is unknown (`durationMs <= 0`), progress is compared as a 0–1 fraction
* instead of milliseconds. Values this close are treated as equal.
*/
private const val ZERO_DURATION_PROGRESS_EPSILON = 0.01f
/**
* Whether a player progress sample should update the cursor, or be ignored because a user seek
* has not been acknowledged yet.
*
* After a seek, the player can keep emitting samples from the old position. Those are dropped
* until a sample is close enough to [pendingSeek].
*
* @param playbackProgress Latest player progress, from 0 to 1.
* @param pendingSeek Target progress of an in-flight user seek, or `null` if none.
* @param durationMs Total media duration in milliseconds. Used to convert a progress delta into time.
* @param playbackSpeed Current playback rate (`1` = realtime). Used when converting the delta to wall-clock time.
* @param syncThresholdMs How close the sample must be to [pendingSeek], in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS].
*/
internal fun shouldApplyPlayerProgress(
playbackProgress: Float,
pendingSeek: Float?,
durationMs: Long,
playbackSpeed: Float,
syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS,
): Boolean {
if (pendingSeek == null) return true
return hasCaughtUpToSeek(
playbackProgress = playbackProgress,
pendingSeek = pendingSeek,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
syncThresholdMs = syncThresholdMs,
)
}
/**
* Whether [playbackProgress] is close enough to [pendingSeek] that the player has likely applied
* the seek.
*
* @param playbackProgress Latest player progress, from 0 to 1.
* @param pendingSeek Target progress of the in-flight user seek, from 0 to 1.
* @param durationMs Total media duration in milliseconds. If `<= 0`, [ZERO_DURATION_PROGRESS_EPSILON] is used instead.
* @param playbackSpeed Current playback rate (`1` = realtime).
* @param syncThresholdMs Allowed wall-clock gap in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS].
*/
internal fun hasCaughtUpToSeek(
playbackProgress: Float,
pendingSeek: Float,
durationMs: Long,
playbackSpeed: Float,
syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS,
): Boolean {
val delta = abs(playbackProgress - pendingSeek)
if (durationMs <= 0L) {
return delta <= ZERO_DURATION_PROGRESS_EPSILON
}
val deltaMs = delta * durationMs / playbackSpeed.coerceAtLeast(0.01f)
return deltaMs <= syncThresholdMs
}
/** Player progress at or below this is treated as "at the start" when detecting a loop/restart. */
private const val RESTART_PROGRESS_EPSILON = 0.01f
/** Animated cursor at or above this is treated as "at the end" when detecting a loop/restart. */
private const val RESTART_ANIMATED_PROGRESS = 0.9f
/**
* Whether the animated cursor should jump to the latest player sample instead of continuing its
* linear animation.
*
* Returns `true` when:
* - playback is paused or duration is unknown (there is no animation to follow)
* - the player is ahead of the cursor by more than [syncThresholdMs]
* - playback restarted from the beginning (cursor near the end, player near the start)
*
* Returns `false` when the values already match, or when the player is only slightly ahead
* (or slightly behind) so the in-flight animation can catch up without a visible jump.
*
* @param playbackProgress Latest player progress, from 0 to 1.
* @param animatedProgress Current animated cursor progress, from 0 to 1.
* @param isPlaying Whether media is currently playing.
* @param durationMs Total media duration in milliseconds. Used to convert a progress delta into time.
* @param playbackSpeed Current playback rate (`1` = realtime). Used when converting the delta to wall-clock time.
* @param syncThresholdMs Drift that is still ignored while playing, in milliseconds. See [PLAYING_SYNC_THRESHOLD_MS].
*/
internal fun shouldSnapToPlayer(
playbackProgress: Float,
animatedProgress: Float,
isPlaying: Boolean,
durationMs: Long,
playbackSpeed: Float,
syncThresholdMs: Long = PLAYING_SYNC_THRESHOLD_MS,
): Boolean {
val delta = abs(playbackProgress - animatedProgress)
if (delta == 0f) return false
if (!isPlaying || durationMs <= 0L) return true
val deltaMs = delta * durationMs / playbackSpeed.coerceAtLeast(0.01f)
if (playbackProgress < animatedProgress) {
return isPlaybackRestart(playbackProgress, animatedProgress)
}
return deltaMs > syncThresholdMs
}
/**
* Detects a loop: the player has wrapped to the start while the cursor is still near the end.
*/
private fun isPlaybackRestart(
playbackProgress: Float,
animatedProgress: Float,
): Boolean {
return playbackProgress <= RESTART_PROGRESS_EPSILON && animatedProgress >= RESTART_ANIMATED_PROGRESS
}
/**
* Remaining wall-clock milliseconds to tween the cursor from [progress] to the end of the media.
*
* Used to time the linear playback animation between infrequent player samples. A result of `0`
* means the cursor is already at (or past) the end and should snap rather than tween.
*
* @param progress Current cursor progress, from 0 to 1.
* @param durationMs Total media duration in milliseconds.
* @param playbackSpeed Current playback rate (`1` = realtime). Faster speeds shorten the result.
*/
internal fun remainingPlaybackAnimationMs(
progress: Float,
durationMs: Long,
playbackSpeed: Float,
): Int {
val remainingProgress = (1f - progress).coerceAtLeast(0f)
return (remainingProgress * durationMs / playbackSpeed.coerceAtLeast(0.01f))
.roundToInt()
.coerceAtLeast(0)
}
@@ -8,17 +8,24 @@
package io.element.android.libraries.designsystem.components.media
import android.view.MotionEvent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
@@ -41,6 +48,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F
@@ -53,6 +61,9 @@ private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F
* @param waveform The waveform to display.
* @param onSeek Callback when the user seeks the waveform. Called with a value between 0 and 1.
* @param modifier The modifier to be applied to the view.
* @param isPlaying Whether playback is currently in progress. Used to drive a linear progress animation.
* @param durationMs The total duration of the media in milliseconds. Used to time the linear animation.
* @param playbackSpeed The current playback speed. Used to time the linear animation.
* @param seekEnabled Whether the user can seek the waveform or not.
* @param brush The brush to use to draw the waveform.
* @param progressBrush The brush to use to draw the progress.
@@ -67,6 +78,9 @@ fun WaveformPlaybackView(
waveform: ImmutableList<Float>,
onSeek: (progress: Float) -> Unit,
modifier: Modifier = Modifier,
isPlaying: Boolean = false,
durationMs: Long = 0L,
playbackSpeed: Float = 1f,
seekEnabled: Boolean = true,
brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary),
progressBrush: Brush = SolidColor(ElementTheme.colors.iconSecondary),
@@ -75,14 +89,36 @@ fun WaveformPlaybackView(
linePadding: Dp = 2.dp,
) {
val seekProgress = remember { mutableStateOf<Float?>(null) }
// Last user-seek target, kept until the player reports a nearby sample.
var pendingSeek by remember { mutableStateOf<Float?>(null) }
var canvasSize by remember { mutableStateOf(DpSize(0.dp, 0.dp)) }
var canvasSizePx by remember { mutableStateOf(Size(0f, 0f)) }
val progress by remember(playbackProgress, seekProgress.value) {
derivedStateOf {
seekProgress.value ?: playbackProgress
}
}
val progressAnimated = animateFloatAsState(targetValue = progress, label = "progressAnimation")
val progressAnimated = remember { Animatable(playbackProgress) }
// Bumped after a snap or user seek so AnimateCursorWhilePlaying restarts its tween.
var seekGeneration by remember { mutableIntStateOf(0) }
val coroutineScope = rememberCoroutineScope()
ApplyPlayerProgressToCursor(
playbackProgress = playbackProgress,
isPlaying = isPlaying,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
pendingSeek = pendingSeek,
seekProgress = seekProgress,
progressAnimated = progressAnimated,
onAcknowledgePendingSeek = { pendingSeek = null },
onCursorSnap = { seekGeneration++ },
)
AnimateCursorWhilePlaying(
isPlaying = isPlaying,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
seekGeneration = seekGeneration,
progressAnimated = progressAnimated,
)
val progress = seekProgress.value ?: progressAnimated.value
val amplitudeDisplayCount by remember(canvasSize, lineWidth, linePadding) {
derivedStateOf {
(canvasSize.width.value / (lineWidth.value + linePadding.value)).toInt()
@@ -107,30 +143,22 @@ fun WaveformPlaybackView(
.let {
if (!seekEnabled) return@let it
it.pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) { e ->
return@pointerInteropFilter when (e.action) {
MotionEvent.ACTION_DOWN -> {
if (e.x in 0F..waveformWidthPx) {
requestDisallowInterceptTouchEvent.invoke(true)
seekProgress.value = e.x / waveformWidthPx
true
} else {
false
handleWaveformPointerEvent(
action = e.action,
x = e.x,
waveformWidthPx = waveformWidthPx,
currentSeekProgress = seekProgress.value,
onDisallowParentIntercept = { requestDisallowInterceptTouchEvent.invoke(it) },
onUpdateSeekProgress = { seekProgress.value = it },
onCommitSeek = { seek ->
pendingSeek = seek
onSeek(seek)
coroutineScope.launch {
progressAnimated.snapTo(seek)
seekGeneration++
}
}
MotionEvent.ACTION_MOVE -> {
if (e.x in 0F..waveformWidthPx) {
seekProgress.value = e.x / waveformWidthPx
}
true
}
MotionEvent.ACTION_UP -> {
requestDisallowInterceptTouchEvent.invoke(false)
seekProgress.value?.let(onSeek)
seekProgress.value = null
true
}
else -> false
}
},
)
}
}
.then(modifier)
@@ -149,7 +177,7 @@ fun WaveformPlaybackView(
drawRect(
brush = progressBrush,
size = Size(
width = progressAnimated.value * waveformWidthPx,
width = progress * waveformWidthPx,
height = canvasSizePx.height
),
blendMode = BlendMode.SrcAtop
@@ -158,7 +186,7 @@ fun WaveformPlaybackView(
drawRoundRect(
brush = cursorBrush,
topLeft = Offset(
x = progressAnimated.value * waveformWidthPx,
x = progress * waveformWidthPx,
y = 1f
),
size = Size(
@@ -172,6 +200,103 @@ fun WaveformPlaybackView(
}
}
/**
* Applies discrete player progress samples to the animated cursor.
*
* Samples that belong to a position before an in-flight user seek are ignored. Once a sample
* is close enough to [pendingSeek], that seek is considered acknowledged. The cursor then
* either snaps to the sample (pause, large jump, or playback restart) or keeps interpolating.
*
* @param playbackProgress Latest player progress, from 0 to 1.
* @param isPlaying Whether media is currently playing.
* @param durationMs Total media duration in milliseconds.
* @param playbackSpeed Current playback rate (`1` = realtime).
* @param pendingSeek Target progress of an in-flight user seek, or `null` if none.
* @param seekProgress Progress while the user is dragging the cursor; non-null means a drag is in progress.
* @param progressAnimated Animated cursor progress.
* @param onAcknowledgePendingSeek Called when a player sample has caught up to [pendingSeek].
* @param onCursorSnap Called after the cursor jumps, so the playing animation can restart from the new position.
*/
@Composable
private fun ApplyPlayerProgressToCursor(
playbackProgress: Float,
isPlaying: Boolean,
durationMs: Long,
playbackSpeed: Float,
pendingSeek: Float?,
seekProgress: State<Float?>,
progressAnimated: Animatable<Float, AnimationVector1D>,
onAcknowledgePendingSeek: () -> Unit,
onCursorSnap: () -> Unit,
) {
val latestOnAcknowledgePendingSeek by rememberUpdatedState(onAcknowledgePendingSeek)
val latestOnCursorSnap by rememberUpdatedState(onCursorSnap)
LaunchedEffect(playbackProgress, isPlaying, durationMs, playbackSpeed, pendingSeek) {
if (seekProgress.value != null) return@LaunchedEffect
if (!shouldApplyPlayerProgress(
playbackProgress = playbackProgress,
pendingSeek = pendingSeek,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
)
) {
return@LaunchedEffect
}
latestOnAcknowledgePendingSeek()
if (shouldSnapToPlayer(
playbackProgress = playbackProgress,
animatedProgress = progressAnimated.value,
isPlaying = isPlaying,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
)
) {
progressAnimated.snapTo(playbackProgress)
latestOnCursorSnap()
}
}
}
/**
* Animates the cursor linearly from its current position to the end of the media.
*
* Player samples are relatively infrequent, so between them we interpolate using the remaining
* duration and [playbackSpeed]. Changing [seekGeneration] (after a snap or user seek) cancels
* the current tween and starts a new one from the updated cursor position.
*
* @param isPlaying Whether media is currently playing. Animation only runs while this is true.
* @param durationMs Total media duration in milliseconds. Must be `> 0` to animate.
* @param playbackSpeed Current playback rate (`1` = realtime). Faster speeds shorten the tween.
* @param seekGeneration Counter incremented when the cursor jumps, used to restart this effect.
* @param progressAnimated Animated cursor progress, tweened toward `1`.
*/
@Composable
private fun AnimateCursorWhilePlaying(
isPlaying: Boolean,
durationMs: Long,
playbackSpeed: Float,
seekGeneration: Int,
progressAnimated: Animatable<Float, AnimationVector1D>,
) {
LaunchedEffect(isPlaying, durationMs, playbackSpeed, seekGeneration) {
if (isPlaying && durationMs > 0L) {
val remainingMs = remainingPlaybackAnimationMs(
progress = progressAnimated.value,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
)
if (remainingMs == 0) {
progressAnimated.snapTo(1f)
} else {
progressAnimated.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = remainingMs, easing = LinearEasing),
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun WaveformPlaybackViewPreview() = ElementPreview {
@@ -55,6 +55,7 @@ fun <T : DropdownOption> PreferenceDropdown(
supportingText: String? = null,
enabled: Boolean = true,
icon: ImageVector? = null,
dropDownIcon: ImageVector = CompoundIcons.ChevronDown(),
@DrawableRes iconResourceId: Int? = null,
showIconAreaIfNoIcon: Boolean = false,
) {
@@ -84,13 +85,14 @@ fun <T : DropdownOption> PreferenceDropdown(
trailingContent = ListItemContent.Custom(
content = { enabled ->
DropdownTrailingContent(
dropDownIcon = dropDownIcon,
selectedOption = selectedOption,
options = options,
onSelectOption = onSelectOption,
expanded = isDropdownExpanded,
onExpandedChange = { isDropdownExpanded = it },
enabled = enabled,
modifier = Modifier.fillMaxSize(0.3f)
modifier = Modifier.fillMaxSize(0.3f),
)
}
),
@@ -119,6 +121,7 @@ private fun <T : DropdownOption> DropdownTrailingContent(
onSelectOption: (T) -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
dropDownIcon: ImageVector = CompoundIcons.ChevronDown(),
) {
Row(
modifier = modifier,
@@ -135,7 +138,7 @@ private fun <T : DropdownOption> DropdownTrailingContent(
modifier = Modifier.weight(1f),
)
Icon(
imageVector = CompoundIcons.ChevronDown(),
imageVector = dropDownIcon,
contentDescription = null,
tint = enabled.toIconSecondaryEnabledColor(),
)
@@ -8,17 +8,18 @@
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FloatingToolbarColors
import androidx.compose.material3.FloatingToolbarDefaults
import androidx.compose.material3.FloatingToolbarHorizontalFabPosition
@@ -28,7 +29,6 @@ import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
@@ -139,8 +139,20 @@ fun HorizontalFloatingToolbarItem(
contentColor = ElementTheme.colors.iconSecondary,
)
}
Box {
FilledIconButton(
BadgedBox(
badge = {
counter?.let {
CounterAtom(
count = it,
// Tweak the offset so it's centered on the icon
modifier = Modifier.offset(x = (-16).dp, y = 10.dp),
contentPadding = PaddingValues(vertical = 0.5.dp),
textStyle = ElementTheme.typography.fontBodySmMedium,
)
}
}
) {
IconButton(
modifier = Modifier.widthIn(min = 56.dp),
colors = colors,
onClick = onClick,
@@ -151,15 +163,6 @@ fun HorizontalFloatingToolbarItem(
contentDescription = tooltipLabel,
)
}
if (counter != null) {
CounterAtom(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 6.dp, end = 3.dp),
count = counter,
textStyle = ElementTheme.typography.fontBodyXsMedium,
)
}
}
}
}
@@ -172,18 +175,47 @@ fun HorizontalFloatingToolbarSeparator(modifier: Modifier = Modifier) {
@PreviewsDayNight
@Composable
internal fun HorizontalFloatingToolbarPreview() = ElementPreview {
ContentToPreview(
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
Column {
ContentToPreview(
count = 1,
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
}
}
}
)
)
ContentToPreview(
count = 99,
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
}
}
)
ContentToPreview(
count = 999,
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
}
}
)
}
}
@PreviewsDayNight
@@ -197,10 +229,11 @@ internal fun HorizontalFloatingToolbarNoFabPreview() = ElementPreview {
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun ContentToPreview(
count: Int? = 6,
floatingActionButton: (@Composable () -> Unit)?,
) {
HorizontalFloatingToolbar(
modifier = Modifier.padding(28.dp),
modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp),
floatingActionButton = floatingActionButton,
) {
listOf(
@@ -214,7 +247,7 @@ private fun ContentToPreview(
icon = icon,
tooltipLabel = "Label",
isSelected = index == 0,
counter = if (index == 0) 6 else null,
counter = if (index == 0) count else null,
forceRenderingTooltip = true,
onClick = { },
)
@@ -0,0 +1,134 @@
/*
* Copyright (c) 2026 Element Creations 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.media
import android.view.MotionEvent
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class WaveformPlaybackSeekTest {
@Test
fun `a down event inside the waveform starts a seek`() {
val result = handleEvent(action = MotionEvent.ACTION_DOWN, x = 40f)
assertThat(result.consumed).isTrue()
assertThat(result.disallowParentIntercept).isTrue()
assertThat(result.seekProgress).isEqualTo(0.4f)
assertThat(result.committedSeek).isNull()
}
@Test
fun `a down event outside the waveform is ignored`() {
val result = handleEvent(action = MotionEvent.ACTION_DOWN, x = 150f)
assertThat(result.consumed).isFalse()
assertThat(result.seekProgress).isNull()
assertThat(result.disallowParentIntercept).isNull()
}
@Test
fun `a move event inside the waveform updates the seek progress`() {
val result = handleEvent(action = MotionEvent.ACTION_MOVE, x = 80f)
assertThat(result.consumed).isTrue()
assertThat(result.seekProgress).isEqualTo(0.8f)
assertThat(result.disallowParentIntercept).isNull()
}
@Test
fun `a move event outside the waveform is still consumed without updating progress`() {
val result = handleEvent(action = MotionEvent.ACTION_MOVE, x = 150f)
assertThat(result.consumed).isTrue()
assertThat(result.seekProgress).isNull()
}
@Test
fun `an up event commits the in-progress seek`() {
val result = handleEvent(
action = MotionEvent.ACTION_UP,
currentSeekProgress = 0.6f,
)
assertThat(result.consumed).isTrue()
assertThat(result.disallowParentIntercept).isFalse()
assertThat(result.committedSeek).isEqualTo(0.6f)
assertThat(result.seekProgress).isNull()
assertThat(result.clearedSeekProgress).isTrue()
}
@Test
fun `an up event without an in-progress seek does not commit`() {
val result = handleEvent(action = MotionEvent.ACTION_UP, currentSeekProgress = null)
assertThat(result.consumed).isTrue()
assertThat(result.committedSeek).isNull()
assertThat(result.clearedSeekProgress).isTrue()
}
@Test
fun `a cancel event clears the in-progress seek without committing`() {
val result = handleEvent(
action = MotionEvent.ACTION_CANCEL,
currentSeekProgress = 0.6f,
)
assertThat(result.consumed).isTrue()
assertThat(result.disallowParentIntercept).isFalse()
assertThat(result.committedSeek).isNull()
assertThat(result.clearedSeekProgress).isTrue()
}
@Test
fun `unknown motion actions are ignored`() {
val result = handleEvent(action = MotionEvent.ACTION_POINTER_DOWN)
assertThat(result.consumed).isFalse()
assertThat(result.seekProgress).isNull()
assertThat(result.committedSeek).isNull()
}
@Test
fun `seek progress is null when the pointer is outside the waveform`() {
assertThat(seekProgressFromPointerX(x = -1f, waveformWidthPx = 100f)).isNull()
assertThat(seekProgressFromPointerX(x = 101f, waveformWidthPx = 100f)).isNull()
assertThat(seekProgressFromPointerX(x = 0f, waveformWidthPx = 100f)).isEqualTo(0f)
assertThat(seekProgressFromPointerX(x = 100f, waveformWidthPx = 100f)).isEqualTo(1f)
}
private fun handleEvent(
action: Int,
x: Float = 0f,
waveformWidthPx: Float = 100f,
currentSeekProgress: Float? = null,
): HandleResult {
var disallowParentIntercept: Boolean? = null
var seekProgress: Float? = currentSeekProgress
var clearedSeekProgress = false
var committedSeek: Float? = null
val consumed = handleWaveformPointerEvent(
action = action,
x = x,
waveformWidthPx = waveformWidthPx,
currentSeekProgress = currentSeekProgress,
onDisallowParentIntercept = { disallowParentIntercept = it },
onUpdateSeekProgress = {
seekProgress = it
if (it == null) clearedSeekProgress = true
},
onCommitSeek = { committedSeek = it },
)
return HandleResult(
consumed = consumed,
disallowParentIntercept = disallowParentIntercept,
seekProgress = seekProgress,
clearedSeekProgress = clearedSeekProgress,
committedSeek = committedSeek,
)
}
private data class HandleResult(
val consumed: Boolean,
val disallowParentIntercept: Boolean?,
val seekProgress: Float?,
val clearedSeekProgress: Boolean,
val committedSeek: Float?,
)
}
@@ -0,0 +1,280 @@
/*
* Copyright (c) 2026 Element Creations 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.media
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class WaveformPlaybackSyncTest {
@Test
fun `paused cursor always snaps to a different player sample`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.5f,
animatedProgress = 0.2f,
isPlaying = false,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `paused cursor does not snap when it already matches the player`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.5f,
animatedProgress = 0.5f,
isPlaying = false,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `playing cursor ignores drift under the sync threshold`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.21f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `playing cursor snaps when the player is ahead beyond the sync threshold`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.3f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `playing cursor does not snap back when the player lags behind the animation`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0f,
animatedProgress = 0.05f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `playing cursor snaps back when playback restarts from the beginning`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0f,
animatedProgress = 1f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `stale player samples are ignored until they catch up to a pending seek`() {
assertThat(
shouldApplyPlayerProgress(
playbackProgress = 0.2f,
pendingSeek = 0.8f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
assertThat(
shouldApplyPlayerProgress(
playbackProgress = 0.8f,
pendingSeek = 0.8f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `player samples apply immediately when there is no pending seek`() {
assertThat(
shouldApplyPlayerProgress(
playbackProgress = 0.2f,
pendingSeek = null,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `playing cursor snaps when duration is unknown`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.5f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 0,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `playing cursor does not snap when drift is exactly the sync threshold`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.22f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `playing cursor snaps back at the restart progress boundary`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.01f,
animatedProgress = 0.9f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.011f,
animatedProgress = 0.9f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
assertThat(
shouldSnapToPlayer(
playbackProgress = 0f,
animatedProgress = 0.89f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isFalse()
}
@Test
fun `faster playback treats the same progress delta as a smaller time gap`() {
assertThat(
shouldSnapToPlayer(
playbackProgress = 0.3f,
animatedProgress = 0.2f,
isPlaying = true,
durationMs = 10_000,
playbackSpeed = 8f,
)
).isFalse()
}
@Test
fun `a pending seek is acknowledged when duration is unknown and the sample is close`() {
assertThat(
hasCaughtUpToSeek(
playbackProgress = 0.8f,
pendingSeek = 0.8f,
durationMs = 0,
playbackSpeed = 1f,
)
).isTrue()
assertThat(
hasCaughtUpToSeek(
playbackProgress = 0.82f,
pendingSeek = 0.8f,
durationMs = 0,
playbackSpeed = 1f,
)
).isFalse()
assertThat(
shouldApplyPlayerProgress(
playbackProgress = 0.805f,
pendingSeek = 0.8f,
durationMs = 0,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `a pending seek is acknowledged at the exact sync threshold`() {
assertThat(
hasCaughtUpToSeek(
playbackProgress = 0.82f,
pendingSeek = 0.8f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isTrue()
}
@Test
fun `zero playback speed still produces a finite remaining animation duration`() {
assertThat(
remainingPlaybackAnimationMs(
progress = 0.5f,
durationMs = 10_000,
playbackSpeed = 0f,
)
).isEqualTo(500_000)
}
@Test
fun `remaining animation duration is zero when the cursor is already at the end`() {
assertThat(
remainingPlaybackAnimationMs(
progress = 1f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isEqualTo(0)
assertThat(
remainingPlaybackAnimationMs(
progress = 1.2f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isEqualTo(0)
}
@Test
fun `remaining animation duration scales with leftover progress and speed`() {
assertThat(
remainingPlaybackAnimationMs(
progress = 0.25f,
durationMs = 10_000,
playbackSpeed = 1f,
)
).isEqualTo(7_500)
assertThat(
remainingPlaybackAnimationMs(
progress = 0.25f,
durationMs = 10_000,
playbackSpeed = 2f,
)
).isEqualTo(3_750)
}
}
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.libraries.designsystem.components.media
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.setSafeContent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class WaveformPlaybackViewTest : RobolectricTest() {
@Test
fun `seeking can be disabled`() = runAndroidComposeUiTest {
setWaveformPlaybackView(seekEnabled = false)
waitForIdle()
}
@Test
fun `a paused cursor follows a new player sample`() = runAndroidComposeUiTest {
val playbackProgress = mutableFloatStateOf(0.2f)
setWaveformPlaybackView(playbackProgress = { playbackProgress.value })
playbackProgress.value = 0.8f
waitForIdle()
}
@Test
fun `playing with a positive duration animates the cursor`() = runAndroidComposeUiTest {
setWaveformPlaybackView(
playbackProgress = { 0f },
isPlaying = true,
durationMs = 50L,
)
waitForIdle()
}
@Test
fun `playing at the end of the media snaps the cursor without tweening`() = runAndroidComposeUiTest {
setWaveformPlaybackView(
playbackProgress = { 1f },
isPlaying = true,
durationMs = 1_000L,
)
waitForIdle()
}
@Test
fun `playing with an unknown duration does not animate`() = runAndroidComposeUiTest {
setWaveformPlaybackView(
playbackProgress = { 0.3f },
isPlaying = true,
durationMs = 0L,
)
waitForIdle()
}
@Test
fun `playing cursor keeps interpolating when the player is only slightly ahead`() = runAndroidComposeUiTest {
val playbackProgress = mutableFloatStateOf(0.2f)
setWaveformPlaybackView(
playbackProgress = { playbackProgress.value },
isPlaying = true,
durationMs = 10_000L,
)
playbackProgress.value = 0.21f
waitForIdle()
}
@Test
fun `an empty waveform still renders with a cursor`() = runAndroidComposeUiTest {
setWaveformPlaybackView(
waveform = persistentListOf(),
showCursor = true,
)
waitForIdle()
}
@Test
fun `a short waveform still renders`() = runAndroidComposeUiTest {
setWaveformPlaybackView(waveform = persistentListOf(0.2f, 0.4f, 0.6f))
waitForIdle()
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setWaveformPlaybackView(
playbackProgress: () -> Float = { 0.5f },
showCursor: Boolean = false,
waveform: ImmutableList<Float> = WaveFormSamples.realisticWaveForm,
isPlaying: Boolean = false,
durationMs: Long = 0L,
playbackSpeed: Float = 1f,
seekEnabled: Boolean = true,
) {
setSafeContent {
ElementTheme {
WaveformPlaybackView(
playbackProgress = playbackProgress(),
showCursor = showCursor,
waveform = waveform,
onSeek = {},
modifier = Modifier
.fillMaxWidth()
.height(34.dp),
isPlaying = isPlaying,
durationMs = durationMs,
playbackSpeed = playbackSpeed,
seekEnabled = seekEnabled,
)
}
}
}
@@ -25,7 +25,7 @@ import java.io.FileOutputStream
*
* It uses hardcoded constants that are used in that library, for backwards compatibility reasons.
*/
internal class EncryptedFile(
class EncryptedFile(
private val context: Context,
private val file: File
) {
@@ -49,10 +49,10 @@
<string name="state_event_room_none_by_you">"你未产生任何更改"</string>
<string name="state_event_room_pinned_events_changed">"%1$s 更改了已置顶的消息"</string>
<string name="state_event_room_pinned_events_changed_by_you">"你更改了已置顶的消息"</string>
<string name="state_event_room_pinned_events_pinned">"%1$s 已置顶 1 个消息"</string>
<string name="state_event_room_pinned_events_pinned_by_you">"你已置顶 1 个消息"</string>
<string name="state_event_room_pinned_events_unpinned">"%1$s 已取消置顶 1 个消息"</string>
<string name="state_event_room_pinned_events_unpinned_by_you">"你取消置顶了 1 个消息"</string>
<string name="state_event_room_pinned_events_pinned">"%1$s 已置顶 1 条消息"</string>
<string name="state_event_room_pinned_events_pinned_by_you">"你已置顶 1 条消息"</string>
<string name="state_event_room_pinned_events_unpinned">"%1$s 已取消置顶 1 条消息"</string>
<string name="state_event_room_pinned_events_unpinned_by_you">"你已取消置顶 1 条消息"</string>
<string name="state_event_room_reject">"%1$s 拒绝了邀请"</string>
<string name="state_event_room_reject_by_you">"你拒绝了邀请"</string>
<string name="state_event_room_remove">"%1$s 已移除 %2$s"</string>
@@ -57,7 +57,6 @@ import org.junit.Test
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@Suppress("LargeClass")
class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
private lateinit var context: Context
private lateinit var fakeMatrixClient: FakeMatrixClient
@@ -58,7 +58,6 @@ import org.junit.Test
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@Suppress("LargeClass")
class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
private lateinit var context: Context
private lateinit var fakeMatrixClient: FakeMatrixClient
@@ -21,7 +21,6 @@ import org.junit.Test
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@Suppress("LargeClass")
class RtcNotificationContentFormatterTest : RobolectricTest() {
private lateinit var context: Context
private lateinit var fakeMatrixClient: FakeMatrixClient
@@ -116,13 +116,6 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
RoomThreadList(
key = "feature.room_thread_list",
title = "Add a list of threads in a room",
description = "Add a new screen with a list of threads in a room.",
defaultValue = { false },
isFinished = false,
),
AutomaticBackPagination(
key = "feature.automatic_back_pagination",
title = "Automatic back pagination of rooms",
@@ -167,5 +160,14 @@ enum class FeatureFlags(
description = "Adds a Select action to the message menu to pick several messages at once.",
defaultValue = { false },
isFinished = false,
isInLabs = false,
),
NewTimelineEventRenderer(
key = "feature.new_timeline_event_renderer",
title = "New timeline event renderer",
description = "Render formatted message bodies natively with Jetpack Compose instead of the rich text editor view.",
defaultValue = { false },
isFinished = false,
isInLabs = false,
),
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.htmlrenderer.api"
}
dependencies {
api(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings)
api(libs.jsoup)
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Element Creations 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.htmlrenderer.api
import io.element.android.libraries.designsystem.components.LINK_TAG
import org.jsoup.nodes.Document
/**
* Parses the HTML [Document] of a formatted message body into a tree of [MessageNode]s
* that a Compose renderer can turn into composables.
*
* The [Document] is expected to have already been sanitized (e.g. via
* `FormattedBody.toHtmlDocument`) so that only supported tags remain.
*/
interface HtmlMessageParser {
/**
* Converts [document] into a [DocumentNode]. Never throws: unknown or empty content
* yields a [DocumentNode] with no children.
*/
fun parse(document: Document): DocumentNode
companion object {
/**
* Tag used for the string annotation attached to link (`<a href>`) text ranges in a
* [ParagraphNode.text]. The annotation value is the link target URL.
*/
const val LINK_ANNOTATION_TAG = LINK_TAG
/**
* Tag used for the string annotation attached to inline code (`<code>`) text ranges in a
* [ParagraphNode.text]. The annotation value is empty; only the range matters. A `<code>`
* nested inside a `<pre>` is part of the [CodeBlockNode] and is never annotated this way.
*/
const val INLINE_CODE_ANNOTATION_TAG = "inline_code"
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Element Creations 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.htmlrenderer.api
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
/**
* Describes a mention "pill" referenced from a [ParagraphNode].
*
* Each mention occupies an inline-content placeholder inside the paragraph's
* [ParagraphNode.text]; the renderer looks the id up in [ParagraphNode.inlineContent]
* and draws the corresponding pill.
*/
@Immutable
sealed interface MentionNodeContent {
/** The text shown inside the pill (e.g. a display name, prefixed with `@` for users). */
val displayText: String
/** A mention of a specific user (`<a href="matrix.to/#/@user:server">`). */
data class User(
override val displayText: String,
val userId: UserId,
) : MentionNodeContent
/** A mention of a room (`<a href="matrix.to/#/#room:server">`). */
data class Room(
override val displayText: String,
val roomIdOrAlias: RoomIdOrAlias,
) : MentionNodeContent
/** An `@room` mention notifying everyone in the room. */
data class Everyone(
override val displayText: String,
) : MentionNodeContent
}
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2026 Element Creations 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.htmlrenderer.api
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
/**
* A node in the parsed representation of a formatted (HTML) message body.
*
* The tree is produced by [HtmlMessageParser] and consumed by a Compose renderer.
* Block-level nodes ([BlockNode]) either contain other block nodes (nested lists, a
* quote containing paragraphs, ...) or terminate in a [ParagraphNode] / [CodeBlockNode]
* that carries the actual text.
*/
@Immutable
sealed interface MessageNode
/** A block-level node, laid out vertically by the renderer. */
@Immutable
sealed interface BlockNode : MessageNode
/**
* The root of a parsed message: an ordered list of block-level children.
*/
@Immutable
data class DocumentNode(
val children: ImmutableList<BlockNode>,
) : BlockNode
/**
* A run of inline content (text with inline formatting) already flattened to an
* [AnnotatedString]. This is the only leaf that carries styled text.
*
* Inline formatting (`<b>`, `<i>`, `<u>`, `<del>`, inline `<code>`) is encoded as
* [androidx.compose.ui.text.SpanStyle] ranges, and links (`<a>`) as string annotations
* tagged [HtmlMessageParser.LINK_ANNOTATION_TAG].
*
* Mentions (pills) are encoded as inline-content placeholders inside [text] (see
* [androidx.compose.foundation.text.appendInlineContent]); [inlineContent] maps each
* placeholder id to the [MentionNodeContent] the renderer needs to draw the pill.
*/
@Immutable
data class ParagraphNode(
val text: AnnotatedString,
val inlineContent: ImmutableMap<String, MentionNodeContent> = persistentMapOf(),
) : BlockNode
/**
* A heading (`<h1>`–`<h6>`). Like a [ParagraphNode] it carries inline content ([text] and
* [inlineContent]), plus the heading [level] from 1 (largest) to 6 (smallest), used by the renderer
* to pick the typography.
*/
@Immutable
data class HeaderNode(
val level: Int,
val text: AnnotatedString,
val inlineContent: ImmutableMap<String, MentionNodeContent> = persistentMapOf(),
) : BlockNode
/**
* A preformatted code block (`<pre>`). Rendered in a monospaced, horizontally
* scrollable container. The text is kept raw: no inline styling is applied inside it.
*/
@Immutable
data class CodeBlockNode(
val code: String,
) : BlockNode
/**
* A block quote (`<blockquote>`). Quotes can nest and can contain any block content.
*/
@Immutable
data class QuoteNode(
val children: ImmutableList<BlockNode>,
) : BlockNode
/**
* An ordered (`<ol>`) or unordered (`<ul>`) list.
*
* @param ordered whether the list is numbered (`<ol>`) or bulleted (`<ul>`).
* @param startIndex the number the first item of an ordered list starts at (honors `ol[start]`, default 1).
* @param items the list items, in document order.
*/
@Immutable
data class ListNode(
val ordered: Boolean,
val startIndex: Int,
val items: ImmutableList<ListItemNode>,
) : BlockNode
/**
* A single list item (`<li>`). Being a block container, an item can itself hold
* paragraphs, nested lists or quotes.
*/
@Immutable
data class ListItemNode(
val children: ImmutableList<BlockNode>,
) : MessageNode
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations 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.htmlrenderer.api.spans
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import io.element.android.compound.theme.ElementTheme
/**
* The style for inline code spans.
*/
val InlineCodeSpanStyle: SpanStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = ElementTheme.typography.fontBodySmRegular.fontSize,
)
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2026 Element Creations 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.htmlrenderer.api.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.api.ListItemNode
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
import kotlinx.collections.immutable.persistentListOf
private val documentNode = DocumentNode(
persistentListOf(
ParagraphNode(AnnotatedString("Hello World, this is a")),
QuoteNode(persistentListOf(ParagraphNode(AnnotatedString("Quote with a very long text that wraps")))),
CodeBlockNode("fun main() {\n println(\"Hello, world!\")\n}"),
ListNode(
ordered = true,
startIndex = 0,
items = persistentListOf(
ListItemNode(persistentListOf(ParagraphNode(AnnotatedString("First item")))),
ListItemNode(persistentListOf(ParagraphNode(AnnotatedString("Second item")))),
ListItemNode(persistentListOf(ParagraphNode(AnnotatedString("Third item")))),
)
)
)
)
@Composable
private fun FakeTimestampView() {
Text(text = "12:34", modifier = Modifier.background(Color.LightGray).padding(start = 12.dp))
}
@Suppress("UnusedPrivateMember")
@Preview(showBackground = true)
@Composable
private fun ContentAvoidingLayoutHtmlMessageContentPreview() {
Box(modifier = Modifier.padding(WindowInsets.safeContent.asPaddingValues())) {
ContentAvoidingLayout(
overlay = { FakeTimestampView() },
content = {
HtmlMessageContent(
modifier = Modifier.background(Color.Red),
node = documentNode,
onContentLayoutChange = this::onContentLayoutChange,
)
}
)
}
}
@@ -0,0 +1,606 @@
/*
* Copyright (c) 2026 Element Creations 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.htmlrenderer.api.ui
import android.content.ClipData
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.linkify
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.htmlrenderer.api.BlockNode
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.api.HeaderNode
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.launch
/**
* Renders a parsed message tree ([DocumentNode]) as native Compose content.
*
* The renderer is stateless: it is driven entirely by [node] and reports interactions
* through the callbacks. It performs no HTML parsing itself.
*
* @param node the root HTML node of the parsed message tree to render.
* @param modifier the [Modifier] to be applied to the layout.
* @param currentUserId the current user ID, if known. This can be used to display UI sent by/owned by the current user differently.
* @param onLinkClick invoked with the target URL when a link is tapped.
* @param onLinkLongClick invoked with the target URL when a link is long-pressed.
* @param onMentionClick invoked when a mention pill is tapped.
* @param onContentLayoutChange invoked with the measured layout of the very last rendered
* [ParagraphNode], so the timeline can lay out the timestamp around it. It is only wired to that
* last paragraph, and only when it is not nested inside a [CodeBlockNode] or [QuoteNode] — in which
* case no measurement is reported.
*/
@Composable
fun HtmlMessageContent(
node: DocumentNode,
modifier: Modifier = Modifier,
currentUserId: UserId? = null,
onLinkClick: (url: String, text: String) -> Unit = { _, _ -> },
onLinkLongClick: (url: String, text: String) -> Unit = { _, _ -> },
onMentionClick: (MentionNodeContent) -> Unit = {},
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) {
val measuredParagraph = remember(node) { node.lastBlockNode() }
val context = remember(
currentUserId,
onLinkClick,
onLinkLongClick,
onMentionClick,
measuredParagraph,
onContentLayoutChange
) {
RenderContext(
currentUserId = currentUserId,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onMentionClick = onMentionClick,
lastBlockNode = measuredParagraph,
onContentLayoutChange = onContentLayoutChange,
)
}
BlockNodes(nodes = node.children, context = context, modifier = modifier)
}
/** Interaction callbacks and state threaded through the render tree. */
@Immutable
private data class RenderContext(
val currentUserId: UserId?,
val onLinkClick: (url: String, text: String) -> Unit,
val onLinkLongClick: (url: String, text: String) -> Unit,
val onMentionClick: (MentionNodeContent) -> Unit,
// The latest descendant BlockNode of the root DocumentNode
val lastBlockNode: BlockNode?,
val onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
)
internal fun BlockNode.lastBlockNode(): BlockNode? = when (this) {
is ParagraphNode -> this
is DocumentNode -> children.lastOrNull()?.lastBlockNode()
is ListNode -> this
is QuoteNode -> this
is CodeBlockNode -> this
is HeaderNode -> this
}
@Composable
private fun BlockNodes(
nodes: ImmutableList<BlockNode>,
context: RenderContext,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(BlockSpacing),
) {
nodes.forEach { BlockNodeView(node = it, context = context) }
}
}
@Composable
private fun BlockNodeView(
node: BlockNode,
context: RenderContext,
modifier: Modifier = Modifier,
) {
if (node === context.lastBlockNode && node !is ParagraphNode && node !is HeaderNode) {
SideEffect { context.onContentLayoutChange(ContentAvoidingLayoutData.NotOverlapping) }
}
when (node) {
is DocumentNode -> BlockNodes(nodes = node.children, context = context, modifier = modifier)
is ParagraphNode -> {
ParagraphView(node = node, context = context, modifier = modifier)
}
is HeaderNode -> HeaderView(node = node, context = context, modifier = modifier)
is CodeBlockNode -> CodeBlockView(node = node, modifier = modifier)
is QuoteNode -> QuoteView(node = node, context = context, modifier = modifier)
is ListNode -> ListView(node = node, context = context, modifier = modifier)
}
}
@Composable
private fun ParagraphView(
node: ParagraphNode,
context: RenderContext,
modifier: Modifier = Modifier,
) {
// Only the last rendered paragraph (and only when not inside a code block / quote) reports its
// measured last line to the timeline.
val measureLastLine = if (node === context.lastBlockNode) {
ContentAvoidingLayout.measureLastTextLine(onContentLayoutChange = context.onContentLayoutChange)
} else {
null
}
TextBlock(
text = node.text,
inlineContent = node.inlineContent,
textStyle = ElementTheme.typography.fontBodyMdRegular,
context = context,
modifier = modifier,
onTextLayout = { measureLastLine?.invoke(it) },
)
}
@Composable
private fun HeaderView(
node: HeaderNode,
context: RenderContext,
modifier: Modifier = Modifier,
) {
// Only the last rendered paragraph (and only when not inside a code block / quote) reports its
// measured last line to the timeline.
val measureLastLine = if (node === context.lastBlockNode) {
ContentAvoidingLayout.measureLastTextLine(onContentLayoutChange = context.onContentLayoutChange)
} else {
null
}
TextBlock(
text = node.text,
inlineContent = node.inlineContent,
textStyle = headerTextStyle(node.level),
context = context,
modifier = modifier,
onTextLayout = { measureLastLine?.invoke(it) }
)
}
/**
* Renders an inline-content block (a paragraph or a header) as a [Text] with [textStyle], applying
* link styling, the inline-code background boxes, mention/image inline content and link taps.
*/
@Composable
private fun TextBlock(
text: AnnotatedString,
inlineContent: ImmutableMap<String, MentionNodeContent>,
textStyle: TextStyle,
context: RenderContext,
modifier: Modifier = Modifier,
onTextLayout: (TextLayoutResult) -> Unit = {},
) {
val linkColor = ElementTheme.colors.textLinkExternal
val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary
val codeBorderColor = ElementTheme.colors.borderInteractiveSecondary
val styledText = remember(text, linkColor) {
text.linkify(SpanStyle(color = linkColor))
.applyLinkStyles(linkColor = linkColor)
}
val resolvedInlineContent = rememberMentionInlineContent(inlineContent, context)
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = styledText,
modifier = modifier
.drawInlineCodeBackgrounds(styledText, layoutResult, codeBackgroundColor, codeBorderColor, LocalDensity.current)
.linkTapHandler(styledText, layoutResult, context),
style = textStyle,
color = ElementTheme.colors.textPrimary,
inlineContent = resolvedInlineContent,
onTextLayout = { result ->
layoutResult.value = result
onTextLayout(result)
},
)
}
/** Maps an `<h1>`–`<h6>` [level] to a Compound typography style, largest first. */
@Composable
private fun headerTextStyle(level: Int): TextStyle = when (level) {
// For safety reasons, use the same style for H1 and H2, to avoid people using H1 for 'shouting' spam that fills the timeline with huge text
1, 2 -> ElementTheme.typography.fontHeadingLgBold
3 -> ElementTheme.typography.fontHeadingMdBold
4 -> ElementTheme.typography.fontHeadingSmMedium
5 -> ElementTheme.typography.fontBodyLgMedium
else -> ElementTheme.typography.fontBodyMdMedium
}
@Composable
private fun CodeBlockView(
node: CodeBlockNode,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val localClipboard = LocalClipboard.current
val clipDataLabel = stringResource(CommonStrings.common_code_block)
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.border(width = 1.dp, color = ElementTheme.colors.borderInteractiveSecondary, shape = RoundedCornerShape(8.dp))
.background(ElementTheme.colors.bgSubtleSecondary)
.height(IntrinsicSize.Min)
.combinedClickable(
onClick = {},
onLongClick = {
coroutineScope.launch {
localClipboard.setClipEntry(ClipEntry(ClipData.newPlainText(clipDataLabel, node.code)))
}
},
onLongClickLabel = stringResource(CommonStrings.action_copy_to_clipboard),
),
) {
Text(
modifier = Modifier.horizontalScroll(scrollState).padding(horizontal = 12.dp, vertical = 8.dp),
text = node.code,
style = ElementTheme.typography.fontBodySmRegular.copy(fontFamily = FontFamily.Monospace),
color = ElementTheme.colors.textPrimary,
softWrap = false,
)
// Previews and screenshots can't render the fading edge gradients properly, everything is obscured by them, so we skip them in the preview mode.
if (LocalInspectionMode.current.not() && (scrollState.canScrollForward || scrollState.canScrollBackward)) {
val progress by remember {
derivedStateOf {
scrollState.value.toFloat() / scrollState.maxValue
}
}
val alpha by animateFloatAsState(targetValue = progress, label = "CodeBlockViewScrollAlpha")
// Draw a fading edge gradient at the left and right of the code block, to hint that it is horizontally scrollable.
Box(
modifier = Modifier
.matchParentSize()
.background(
brush = Brush.horizontalGradient(
colors = listOf(ElementTheme.colors.bgSubtleTertiary.copy(alpha = alpha), Color.Transparent),
startX = 0f,
endX = CodeBlockFadingEdgeWidth.toPx(),
)
)
)
Box(
modifier = Modifier
.matchParentSize()
.background(
brush = Brush.horizontalGradient(
colors = listOf(Color.Transparent, ElementTheme.colors.bgSubtleTertiary.copy(alpha = 1f - alpha)),
startX = scrollState.viewportSize - CodeBlockFadingEdgeWidth.toPx(),
endX = scrollState.viewportSize.toFloat(),
)
)
)
}
}
}
@Composable
private fun QuoteView(
node: QuoteNode,
context: RenderContext,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier.height(IntrinsicSize.Min)) {
Box(
modifier = Modifier
.width(2.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(1.dp))
.background(ElementTheme.colors.borderInteractiveSecondary),
)
Spacer(modifier = Modifier.width(8.dp))
BlockNodes(nodes = node.children, context = context)
}
}
@Composable
private fun ListView(
node: ListNode,
context: RenderContext,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(ListItemSpacing),
) {
node.items.forEachIndexed { index, item ->
Row {
Text(
text = if (node.ordered) "${node.startIndex + index}." else "•",
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
modifier = Modifier.widthIn(min = ListMarkerWidth),
textAlign = if (node.ordered) TextAlign.Start else TextAlign.Center,
)
BlockNodes(nodes = item.children, context = context)
}
}
}
}
@Composable
private fun MentionPill(
mention: MentionNodeContent,
isCurrentUser: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val backgroundColor = if (isCurrentUser) ElementTheme.colors.bgBadgeAccent else ElementTheme.colors.bgBadgePrimary
val textColor = if (isCurrentUser) ElementTheme.colors.textBadgeAccent else ElementTheme.colors.textOnSolidPrimary
Box(
modifier = modifier
.fillMaxSize()
.clip(RoundedCornerShape(percent = 50))
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(horizontal = PillPaddingHorizontal, vertical = PillPaddingVertical),
contentAlignment = Alignment.Center,
) {
Text(
text = mention.displayText,
style = ElementTheme.typography.fontBodyLgMedium,
color = textColor,
maxLines = 1,
)
}
}
/**
* Builds an [InlineTextContent] entry for each mention placeholder. Each pill is measured so
* the placeholder reserves exactly the space the pill needs.
*/
@Composable
private fun rememberMentionInlineContent(
mentions: ImmutableMap<String, MentionNodeContent>,
context: RenderContext,
): ImmutableMap<String, InlineTextContent> {
if (mentions.isEmpty()) return persistentMapOf()
val textMeasurer = rememberTextMeasurer()
val pillTextStyle = ElementTheme.typography.fontBodyLgMedium
val density = LocalDensity.current
return mentions.mapValues { (_, mention) ->
val measured = textMeasurer.measure(mention.displayText, pillTextStyle)
val width = with(density) { measured.size.width.toDp() + PillPaddingHorizontal * 2 }
val height = with(density) { measured.size.height.toDp() + PillPaddingVertical * 2 }
InlineTextContent(
placeholder = Placeholder(
width = with(density) { width.toSp() },
height = with(density) { height.toSp() },
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
),
) {
MentionPill(
mention = mention,
isCurrentUser = mention is MentionNodeContent.User && mention.userId == context.currentUserId,
onClick = { context.onMentionClick(mention) },
)
}
}.toImmutableMap()
}
/** Overlays the theme-dependent link color onto the link-annotated ranges. */
private fun AnnotatedString.applyLinkStyles(linkColor: Color): AnnotatedString {
val linkRanges = getStringAnnotations(LINK_ANNOTATION_TAG, 0, length)
if (linkRanges.isEmpty()) return this
return buildAnnotatedString {
append(this@applyLinkStyles)
linkRanges.forEach { linkRange ->
val containsColorSpan =
spanStyles.any { colorRange -> colorRange.start == linkRange.start && colorRange.end == linkRange.end && colorRange.item.color == linkColor }
if (!containsColorSpan) {
addStyle(SpanStyle(color = linkColor), linkRange.start, linkRange.end)
}
}
}
}
/**
* Draws a rounded, bordered box behind each inline code ([INLINE_CODE_ANNOTATION_TAG]) range, so
* inline code matches the look of [CodeBlockView] but only covers the annotated text. A range that
* wraps across several lines gets one box per line.
*/
private fun Modifier.drawInlineCodeBackgrounds(
text: AnnotatedString,
layoutResult: State<TextLayoutResult?>,
backgroundColor: Color,
borderColor: Color,
density: Density,
): Modifier = drawBehind {
val layout = layoutResult.value ?: return@drawBehind
val codeRanges = text.getStringAnnotations(INLINE_CODE_ANNOTATION_TAG, 0, text.length)
if (codeRanges.isEmpty()) return@drawBehind
val strokeWidth = InlineCodeBorderWidth.toPx()
val horizontalPadding = InlineCodeHorizontalPadding.toPx()
codeRanges.forEach { range ->
val firstLine = layout.getLineForOffset(range.start)
val lastLine = layout.getLineForOffset(range.end)
for (line in firstLine..lastLine) {
val lineStart = maxOf(range.start, layout.getLineStart(line))
val lineEnd = minOf(range.end, layout.getLineEnd(line, visibleEnd = true))
if (lineEnd <= lineStart) continue
val startX = layout.getHorizontalPosition(lineStart, usePrimaryDirection = true)
val endX = layout.getHorizontalPosition(lineEnd, usePrimaryDirection = true)
val left = minOf(startX, endX) - horizontalPadding
val right = maxOf(startX, endX) + horizontalPadding
val topLeft = Offset(left, layout.getLineTop(line))
val size = Size(right - left, layout.getLineBottom(line) - layout.getLineTop(line))
val shape = RoundedCornerShape(
topStart = if (line == firstLine) InlineCodeCornerRadius else 0.dp,
topEnd = if (line == lastLine) InlineCodeCornerRadius else 0.dp,
bottomStart = if (line == firstLine) InlineCodeCornerRadius else 0.dp,
bottomEnd = if (line == lastLine) InlineCodeCornerRadius else 0.dp,
)
val outline = shape.createOutline(
size = size,
layoutDirection = layoutDirection,
density = density,
)
translate(topLeft.x, topLeft.y) {
drawOutline(outline = outline, color = backgroundColor, style = Fill)
drawOutline(outline = outline, color = borderColor, style = Stroke(width = strokeWidth))
}
}
}
}
/**
* Detects taps and long presses on link-annotated ranges ([LINK_ANNOTATION_TAG]) without consuming the touch events if none are detected, and invokes the
* appropriate callbacks in [context].
*/
private fun Modifier.linkTapHandler(
text: AnnotatedString,
layoutResult: State<TextLayoutResult?>,
context: RenderContext,
): Modifier = pointerInput(text) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = true)
val (url, urlText) = layoutResult.value?.urlAt(down.position, text) ?: return@awaitEachGesture
down.consume()
val longPressTimeout = viewConfiguration.longPressTimeoutMillis
// Try detecting a long press by waiting for the long press timeout and checking if we received an up event before that.
// If we did, it's a tap, otherwise it's a long press.
// This warning is a false positive.
@Suppress("KotlinConstantConditions")
val isLongPress = try {
withTimeout(longPressTimeout) {
var receivedUp = false
while (!receivedUp) {
val event = awaitPointerEvent()
val up = event.changes.firstOrNull { it.id == down.id && it.changedToUp() }
if (up != null) {
receivedUp = true
up.consume()
}
}
!receivedUp
}
} catch (_: PointerEventTimeoutCancellationException) {
true
}
if (isLongPress) {
context.onLinkLongClick(url, urlText)
} else {
context.onLinkClick(url, urlText)
}
}
}
private fun TextLayoutResult.urlAt(offset: Offset, text: AnnotatedString): Pair<String, String>? {
val position = getOffsetForPosition(offset)
val urlAnnotation = text.getStringAnnotations(LINK_ANNOTATION_TAG, position, position).firstOrNull() ?: return null
return urlAnnotation.item to text.substring(urlAnnotation.start, urlAnnotation.end)
}
private val BlockSpacing: Dp = 8.dp
private val ListItemSpacing: Dp = 4.dp
private val ListMarkerWidth: Dp = 24.dp
private val PillPaddingHorizontal: Dp = 6.dp
private val PillPaddingVertical: Dp = 0.dp
/** Rounded, bordered box drawn behind inline code. */
private val InlineCodeCornerRadius: Dp = 6.dp
private val InlineCodeBorderWidth: Dp = 1.dp
private val InlineCodeHorizontalPadding: Dp = 2.dp
/** The width of the fading edge gradient drawn at the left and right of a [CodeBlockView]. */
private val CodeBlockFadingEdgeWidth: Dp = 32.dp
@@ -0,0 +1,262 @@
/*
* Copyright (c) 2026 Element Creations 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.htmlrenderer.api.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withAnnotation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.htmlrenderer.api.BlockNode
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.api.HeaderNode
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.ListItemNode
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.htmlrenderer.api.spans.InlineCodeSpanStyle
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
@PreviewsDayNight
@Composable
internal fun HtmlMessageContentPreview(
@PreviewParameter(HtmlMessageContentPreviewParam::class) node: DocumentNode,
) = ElementPreview {
HtmlMessageContent(
node = node,
currentUserId = UserId("@me:example.org"),
modifier = Modifier.padding(16.dp),
)
}
internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<DocumentNode> {
override val values: Sequence<DocumentNode>
get() = sequenceOf(
headers(),
richParagraph(),
inlineCode(),
multilineInlineCode(),
orderedList(),
quote(),
nestedQuotes(),
codeBlock(),
mention(),
mentionOwn(),
nestedList(),
nestedListWithComplexContents(),
linkifiedParagraph(),
)
private fun richParagraph() = document(
paragraph(
buildAnnotatedString {
append("Hello ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("bold") }
append(", ")
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append("italic") }
append(" and a ")
val start = length
append("link")
addStringAnnotation(LINK_ANNOTATION_TAG, "https://element.io", start, length)
}
),
)
private fun linkifiedParagraph() = document(
paragraph(
buildAnnotatedString {
append("Hello me@matrix.org, check out element.io and and call +1 234 567 8900, then get in touch with ")
appendInlineContent(MENTION_ID, "@alice:example.org")
append(".\n\n")
append("Also, ignore this link: ")
val start = length
append("https://matrix.org")
addStringAnnotation(LINK_ANNOTATION_TAG, "https://matrix.org", start, length)
append(" and this one: ")
withAnnotation(INLINE_CODE_ANNOTATION_TAG, "") {
append("https://element.io")
}
append(".")
},
inlineContent = persistentMapOf(
MENTION_ID to MentionNodeContent.User(displayText = "@alice", userId = UserId("@alice:example.org")),
),
),
)
private fun inlineCode() = document(
paragraph(
buildAnnotatedString {
append("Run ")
val start = length
withStyle(InlineCodeSpanStyle) { append("git status") }
addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, length)
append(" to see changes")
}
),
)
private fun multilineInlineCode() = document(
QuoteNode(persistentListOf(
paragraph(
buildAnnotatedString {
append("Trying ")
val start = length
withStyle(InlineCodeSpanStyle) {
append("inline code when it needs to be wrapped in several lines, just to check what it looks like")
}
addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, length)
append(", is it good?")
}
),
))
)
private fun orderedList() = document(
ListNode(
ordered = true,
startIndex = 1,
items = persistentListOf(
ListItemNode(persistentListOf(paragraph(AnnotatedString("First item")))),
ListItemNode(persistentListOf(paragraph(AnnotatedString("Second item")))),
),
),
)
private fun quote() = document(
paragraph(AnnotatedString("Someone said:")),
QuoteNode(persistentListOf(paragraph(AnnotatedString("This is a quoted message.")))),
)
private fun nestedQuotes() = document(
paragraph(AnnotatedString("Someone said:")),
QuoteNode(persistentListOf(
paragraph(AnnotatedString("This is a quoted message.")),
QuoteNode(persistentListOf(
paragraph(AnnotatedString("This is a nested quote.")),
codeBlock(),
)),
)),
)
private fun codeBlock() = document(
CodeBlockNode("fun main() {\n println(\"Hello, world!\")\n}"),
)
private fun mention() = document(
paragraph(
text = buildAnnotatedString {
append("Hey ")
appendInlineContent(MENTION_ID, "@alice")
append(", welcome!")
},
inlineContent = persistentMapOf(
MENTION_ID to MentionNodeContent.User(displayText = "@alice", userId = UserId("@alice:example.org")),
),
),
)
private fun mentionOwn() = document(
paragraph(
text = buildAnnotatedString {
append("Hey ")
appendInlineContent(MENTION_ID, "@me")
append(", welcome!")
},
inlineContent = persistentMapOf(
MENTION_ID to MentionNodeContent.User(displayText = "@me", userId = UserId("@me:example.org")),
),
),
)
private fun nestedList() = document(
ListNode(
ordered = false,
startIndex = 1,
items = persistentListOf(
ListItemNode(
persistentListOf(
paragraph(AnnotatedString("Parent item")),
ListNode(
ordered = false,
startIndex = 1,
items = persistentListOf(
ListItemNode(persistentListOf(paragraph(AnnotatedString("Nested item")))),
),
),
),
),
),
),
)
private fun nestedListWithComplexContents() = document(
ListNode(
ordered = false,
startIndex = 1,
items = persistentListOf(
ListItemNode(
persistentListOf(
paragraph(AnnotatedString("Parent item")),
ListNode(
ordered = false,
startIndex = 1,
items = persistentListOf(
ListItemNode(persistentListOf(
paragraph(AnnotatedString("Nested item")),
codeBlock(),
quote(),
)),
),
),
),
),
),
),
)
private fun headers() = document(
HeaderNode(level = 1, text = AnnotatedString("Heading 1")),
HeaderNode(level = 2, text = AnnotatedString("Heading 2")),
HeaderNode(level = 3, text = AnnotatedString("Heading 3")),
HeaderNode(level = 4, text = AnnotatedString("Heading 4")),
HeaderNode(level = 5, text = AnnotatedString("Heading 5")),
HeaderNode(level = 6, text = AnnotatedString("Heading 6")),
paragraph(AnnotatedString("Body text under the headings.")),
)
private fun document(vararg blocks: BlockNode) = DocumentNode(blocks.toList().toImmutableList())
private fun paragraph(
text: AnnotatedString,
inlineContent: ImmutableMap<String, MentionNodeContent> = persistentMapOf(),
) = ParagraphNode(text = text, inlineContent = inlineContent)
private companion object {
const val MENTION_ID = "mention_0"
}
}
@@ -0,0 +1,35 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.htmlrenderer.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
api(projects.libraries.htmlrenderer.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.uiCommon)
implementation(libs.jsoup)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
}
@@ -0,0 +1,259 @@
/*
* Copyright (c) 2026 Element Creations 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.htmlrenderer.impl
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.htmlrenderer.api.BlockNode
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.api.HeaderNode
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.ListItemNode
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.htmlrenderer.api.spans.InlineCodeSpanStyle
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
/**
* Recursive-descent [HtmlMessageParser] over a (sanitized) jsoup [Document].
*
* Block-level elements become their matching [BlockNode]; consecutive inline nodes are
* coalesced into a single [ParagraphNode] whose text is an [AnnotatedString].
*/
@ContributesBinding(AppScope::class)
class DefaultHtmlMessageParser(
private val permalinkParser: PermalinkParser,
) : HtmlMessageParser {
override fun parse(document: Document): DocumentNode {
return DocumentNode(children = parseBlocks(document.body()))
}
/**
* Walks the children of [parent], emitting a block node for each block-level element and
* buffering runs of inline content into [ParagraphNode]s in between.
*/
private fun parseBlocks(parent: Element): ImmutableList<BlockNode> {
val blocks = mutableListOf<BlockNode>()
val inlineBuffer = mutableListOf<Node>()
fun flushInline() {
if (inlineBuffer.isNotEmpty()) {
buildParagraph(inlineBuffer)?.let(blocks::add)
inlineBuffer.clear()
}
}
for (node in parent.childNodes()) {
when (node) {
// The fallback reply content is never rendered.
is Element if node.tagName() == TAG_MX_REPLY -> Unit
is Element if node.isBlockLevel() -> {
flushInline()
parseBlock(node)?.let(blocks::add)
}
else -> inlineBuffer.add(node)
}
}
flushInline()
return blocks.toImmutableList()
}
private fun parseBlock(element: Element): BlockNode? = when (val tag = element.tagName()) {
TAG_P -> buildParagraph(element.childNodes())
TAG_PRE -> CodeBlockNode(code = element.wholeText().trimEnd('\n'))
TAG_BLOCKQUOTE -> QuoteNode(children = parseBlocks(element))
TAG_UL -> buildList(element, ordered = false)
TAG_OL -> buildList(element, ordered = true)
in HEADER_TAGS -> buildHeader(element, level = tag.removePrefix("h").toInt())
else -> null
}
private fun buildList(element: Element, ordered: Boolean): ListNode {
val startIndex = if (ordered) element.attr("start").toIntOrNull() ?: 1 else 1
val items = element.childNodes()
.filterIsInstance<Element>()
.filter { it.tagName() == TAG_LI }
.map { ListItemNode(children = parseBlocks(it)) }
.toImmutableList()
return ListNode(ordered = ordered, startIndex = startIndex, items = items)
}
/** Builds a [ParagraphNode] from a run of inline [nodes], or `null` if it holds no content. */
private fun buildParagraph(nodes: List<Node>): ParagraphNode? {
return buildInlineContent(nodes)?.let { ParagraphNode(text = it.text, inlineContent = it.inlineContent) }
}
/** Builds a [HeaderNode] of the given [level] from the inline content of [element], or `null` if empty. */
private fun buildHeader(element: Element, level: Int): HeaderNode? {
return buildInlineContent(element.childNodes())?.let {
HeaderNode(level = level, text = it.text, inlineContent = it.inlineContent)
}
}
private fun buildInlineContent(nodes: List<Node>): InlineContentBuilder.Result? {
val builder = InlineContentBuilder()
nodes.forEach { builder.append(it) }
return builder.build()
}
/**
* Accumulates inline nodes into an [AnnotatedString] plus a map of mention placeholders.
* A fresh instance is used for each paragraph so placeholder ids stay local to it.
*/
private inner class InlineContentBuilder {
private val builder = AnnotatedString.Builder()
private val mentions = mutableMapOf<String, MentionNodeContent>()
private var mentionCount = 0
fun append(node: Node) {
when (node) {
is TextNode -> builder.append(node.text())
is Element -> appendElement(node)
else -> Unit
}
}
private fun appendElement(element: Element) {
when (element.tagName()) {
TAG_BR -> builder.append("\n")
TAG_B, TAG_STRONG -> withStyle(SpanStyle(fontWeight = FontWeight.Bold), element)
TAG_I, TAG_EM -> withStyle(SpanStyle(fontStyle = FontStyle.Italic), element)
TAG_U -> withStyle(SpanStyle(textDecoration = TextDecoration.Underline), element)
TAG_DEL, TAG_STRIKE_SHORT, TAG_STRIKE -> withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), element)
TAG_CODE -> appendInlineCode(element)
TAG_A -> appendLink(element)
TAG_MX_REPLY -> Unit
// Unknown inline wrapper: keep its text so nothing is dropped.
else -> appendChildren(element)
}
}
private fun appendChildren(element: Element) {
element.childNodes().forEach { append(it) }
}
private fun withStyle(style: SpanStyle, element: Element) {
val start = builder.length
appendChildren(element)
builder.addStyle(style, start, builder.length)
}
/**
* Renders an inline `<code>` as monospaced text and annotates the range so the renderer can
* apply a code background. A `<code>` inside a `<pre>` never reaches here: the `<pre>` is
* turned into a [CodeBlockNode] from its raw text.
*/
private fun appendInlineCode(element: Element) {
val start = builder.length
appendChildren(element)
if (builder.length > start) {
builder.addStyle(InlineCodeSpanStyle, start, builder.length)
builder.addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, builder.length)
}
}
private fun appendLink(element: Element) {
val href = element.attr("href")
val mention = href.takeIf { it.isNotEmpty() }?.let { resolveMention(element, it) }
if (mention != null) {
val id = "$MENTION_ID_PREFIX${mentionCount++}"
mentions[id] = mention
builder.appendInlineContent(id, mention.displayText)
return
}
// Only the link range is annotated; the visual style (color, ...) is applied by the renderer.
val start = builder.length
appendChildren(element)
if (href.isNotEmpty() && builder.length > start) {
builder.addStringAnnotation(LINK_ANNOTATION_TAG, href, start, builder.length)
}
}
private fun resolveMention(element: Element, href: String): MentionNodeContent? {
return when (val permalink = permalinkParser.parse(href)) {
is PermalinkData.UserLink -> MentionNodeContent.User(
displayText = element.text(),
userId = permalink.userId,
)
is PermalinkData.RoomLink -> if (permalink.eventId == null) {
MentionNodeContent.Room(
displayText = element.text(),
roomIdOrAlias = permalink.roomIdOrAlias,
)
} else {
// A link to a specific event is a regular link, not a mention pill.
null
}
else -> null
}
}
fun build(): Result? {
val text = builder.toAnnotatedString()
if (text.isBlank() && mentions.isEmpty()) return null
return Result(
text = text,
inlineContent = mentions.toImmutableMap(),
)
}
/** The flattened inline content of a block: styled text plus its inline-content placeholders. */
inner class Result(
val text: AnnotatedString,
val inlineContent: ImmutableMap<String, MentionNodeContent>,
)
}
}
/** Tags that start a new block-level node. Anything else is treated as inline content. */
private fun Element.isBlockLevel(): Boolean = tagName() in BLOCK_TAGS
private val HEADER_TAGS = setOf("h1", "h2", "h3", "h4", "h5", "h6")
private val BLOCK_TAGS = setOf(TAG_P, TAG_PRE, TAG_BLOCKQUOTE, TAG_UL, TAG_OL) + HEADER_TAGS
private const val TAG_P = "p"
private const val TAG_PRE = "pre"
private const val TAG_BLOCKQUOTE = "blockquote"
private const val TAG_UL = "ul"
private const val TAG_OL = "ol"
private const val TAG_LI = "li"
private const val TAG_BR = "br"
private const val TAG_B = "b"
private const val TAG_STRONG = "strong"
private const val TAG_I = "i"
private const val TAG_EM = "em"
private const val TAG_U = "u"
private const val TAG_DEL = "del"
private const val TAG_STRIKE_SHORT = "s"
private const val TAG_STRIKE = "strike"
private const val TAG_CODE = "code"
private const val TAG_A = "a"
private const val TAG_MX_REPLY = "mx-reply"
private const val MENTION_ID_PREFIX = "mention_"
@@ -0,0 +1,223 @@
/*
* Copyright (c) 2026 Element Creations 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.htmlrenderer.impl
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
import io.element.android.libraries.htmlrenderer.api.HeaderNode
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.jsoup.Jsoup
import org.junit.Test
class DefaultHtmlMessageParserTest : RobolectricTest() {
@Test
fun `plain text becomes a single paragraph`() {
val document = parse("Hello world")
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Hello world")
assertThat(paragraph.inlineContent).isEmpty()
}
@Test
fun `inline formatting is encoded as span styles`() {
val document = parse("Hello <strong>bold</strong> and <em>italic</em>")
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Hello bold and italic")
val boldStart = paragraph.text.text.indexOf("bold")
val bold = paragraph.text.spanStyles.single { it.item.fontWeight == FontWeight.Bold }
assertThat(bold.start).isEqualTo(boldStart)
assertThat(bold.end).isEqualTo(boldStart + "bold".length)
val italicStart = paragraph.text.text.indexOf("italic")
val italic = paragraph.text.spanStyles.single { it.item.fontStyle == FontStyle.Italic }
assertThat(italic.start).isEqualTo(italicStart)
assertThat(italic.end).isEqualTo(italicStart + "italic".length)
}
@Test
fun `unordered list produces a bulleted ListNode`() {
val document = parse("<ul><li>First</li><li>Second</li></ul>")
val list = document.children.single() as ListNode
assertThat(list.ordered).isFalse()
assertThat(list.startIndex).isEqualTo(1)
assertThat(list.items).hasSize(2)
val firstItem = list.items[0].children.single() as ParagraphNode
assertThat(firstItem.text.text).isEqualTo("First")
}
@Test
fun `ordered list honors the start attribute`() {
val document = parse("""<ol start="5"><li>Fifth</li></ol>""")
val list = document.children.single() as ListNode
assertThat(list.ordered).isTrue()
assertThat(list.startIndex).isEqualTo(5)
}
@Test
fun `a list nested inside a list item is preserved`() {
val document = parse("<ul><li>Parent<ul><li>Child</li></ul></li></ul>")
val outerList = document.children.single() as ListNode
val outerItem = outerList.items.single()
// The item holds a paragraph ("Parent") followed by the nested list.
val paragraph = outerItem.children[0] as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Parent")
val nestedList = outerItem.children[1] as ListNode
val nestedItem = nestedList.items.single().children.single() as ParagraphNode
assertThat(nestedItem.text.text).isEqualTo("Child")
}
@Test
fun `blockquote wraps its block children`() {
val document = parse("<blockquote><p>Quoted</p></blockquote>")
val quote = document.children.single() as QuoteNode
val paragraph = quote.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Quoted")
}
@Test
fun `blockquotes can nest`() {
val document = parse("<blockquote><blockquote><p>Deep</p></blockquote></blockquote>")
val outer = document.children.single() as QuoteNode
val inner = outer.children.single() as QuoteNode
val paragraph = inner.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Deep")
}
@Test
fun `inline code is monospaced and annotated`() {
val document = parse("Run <code>git status</code> now")
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Run git status now")
val start = paragraph.text.text.indexOf("git status")
val end = start + "git status".length
val monospace = paragraph.text.spanStyles.single { it.item.fontFamily == FontFamily.Monospace }
assertThat(monospace.start).isEqualTo(start)
assertThat(monospace.end).isEqualTo(end)
val codeAnnotations = paragraph.text.getStringAnnotations(HtmlMessageParser.INLINE_CODE_ANNOTATION_TAG, start, end)
assertThat(codeAnnotations).hasSize(1)
}
@Test
fun `pre becomes a code block with raw text, and its inner code is not treated as inline code`() {
val document = parse("<pre><code>val x = 1\nval y = 2</code></pre>")
val codeBlock = document.children.single() as CodeBlockNode
assertThat(codeBlock.code).isEqualTo("val x = 1\nval y = 2")
}
@Test
fun `a regular link is styled and annotated with its url`() {
val permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(android.net.Uri.parse(it)) }
val document = parse("""Visit <a href="https://element.io">the site</a>""", permalinkParser)
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.inlineContent).isEmpty()
val linkStart = paragraph.text.text.indexOf("the site")
val annotations = paragraph.text.getStringAnnotations(
tag = HtmlMessageParser.LINK_ANNOTATION_TAG,
start = linkStart,
end = linkStart + "the site".length,
)
assertThat(annotations.single().item).isEqualTo("https://element.io")
}
@Test
fun `a user link becomes a mention pill`() {
val userId = UserId("@alice:example.org")
val permalinkParser = FakePermalinkParser { PermalinkData.UserLink(userId) }
val document = parse("""Hi <a href="https://matrix.to/#/@alice:example.org">Alice</a>""", permalinkParser)
val paragraph = document.children.single() as ParagraphNode
val mention = paragraph.inlineContent.values.single()
assertThat(mention).isEqualTo(MentionNodeContent.User(displayText = "Alice", userId = userId))
// The placeholder id in the text matches the map key.
val placeholderId = paragraph.inlineContent.keys.single()
val annotations = paragraph.text.getStringAnnotations(0, paragraph.text.length)
assertThat(annotations.any { it.item == placeholderId }).isTrue()
}
@Test
fun `a room link becomes a room mention pill`() {
val roomIdOrAlias = RoomAlias("#room:example.org").toRoomIdOrAlias()
val permalinkParser = FakePermalinkParser {
PermalinkData.RoomLink(roomIdOrAlias = roomIdOrAlias)
}
val document = parse("""See <a href="https://matrix.to/#/#room:example.org">the room</a>""", permalinkParser)
val paragraph = document.children.single() as ParagraphNode
val mention = paragraph.inlineContent.values.single()
assertThat(mention).isEqualTo(
MentionNodeContent.Room(displayText = "the room", roomIdOrAlias = roomIdOrAlias)
)
}
@Test
fun `a link to an event is a regular link, not a mention`() {
val permalinkParser = FakePermalinkParser {
PermalinkData.RoomLink(
roomIdOrAlias = RoomId("!room:example.org").toRoomIdOrAlias(),
eventId = EventId("\$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg"),
)
}
val document = parse("""Look <a href="https://matrix.to/#/!room:example.org/event">here</a>""", permalinkParser)
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.inlineContent).isEmpty()
}
@Test
fun `mx-reply content is stripped`() {
val document = parse("<mx-reply>In reply to something</mx-reply>Actual message")
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Actual message")
}
@Test
fun `headers become HeaderNodes with their level and inline content`() {
val document = parse("<h1>Title</h1><h3>Subtitle with <strong>bold</strong></h3><h6>Small</h6>")
assertThat(document.children).hasSize(3)
val h1 = document.children[0] as HeaderNode
assertThat(h1.level).isEqualTo(1)
assertThat(h1.text.text).isEqualTo("Title")
val h3 = document.children[1] as HeaderNode
assertThat(h3.level).isEqualTo(3)
assertThat(h3.text.text).isEqualTo("Subtitle with bold")
val h6 = document.children[2] as HeaderNode
assertThat(h6.level).isEqualTo(6)
assertThat(h6.text.text).isEqualTo("Small")
}
@Test
fun `blank input produces an empty document`() {
val document = parse(" ")
assertThat(document.children).isEmpty()
}
private fun parse(
html: String,
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
) = DefaultHtmlMessageParser(permalinkParser).parse(Jsoup.parse(html))
}
@@ -1,24 +1,18 @@
import extension.buildConfigFieldBoolean
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "org.rustls.platformverifier"
buildFeatures {
buildConfig = true
}
defaultConfig {
buildConfigFieldBoolean("TEST", false)
}
namespace = "io.element.android.libraries.htmlrenderer.test"
}
dependencies {
implementation(projects.libraries.htmlrenderer.api)
implementation(projects.tests.testutils)
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations 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.htmlrenderer.test
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
import io.element.android.tests.testutils.lambda.lambdaError
import org.jsoup.nodes.Document
class FakeHtmlMessageParser(
private val parseResult: (Document) -> DocumentNode = { lambdaError() },
) : HtmlMessageParser {
override fun parse(document: Document): DocumentNode = parseResult(document)
}
@@ -326,6 +326,11 @@ interface MatrixClient : ClientUrlContentFetcher {
*/
suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String>
/**
* Returns the latest [RoomInfo] of a room, if known to the client, or `null` if the room is unknown.
*/
suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?>
/**
* Get a room info flow for a given room ID.
* The flow will emit a new value whenever the room info is updated, skipping duplicates, and an empty [Optional] if the room is not found.
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2026 Element Creations 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.matrix.api.accountprovider
import androidx.compose.runtime.Immutable
@Immutable
sealed interface AccountProvider {
/**
* An account provider that was input by the user.
*/
data class Generic(
val serverName: String,
) : AccountProvider
/**
* An account provider that is provided by the app.
*/
data class Managed(
val serverName: String,
val baseUrl: String,
val canUseServerName: Boolean,
) : AccountProvider
fun serverNameOrBaseUrl(): String {
return when (this) {
is Generic -> serverName
is Managed -> if (canUseServerName) serverName else baseUrl
}.sanitized()
}
/**
* Return the server name, to be used in the UI.
*/
fun friendlyServerName(): String {
return when (this) {
is Generic -> serverName
is Managed -> serverName
}.sanitized()
}
/**
* The user may reach an allowed account provider by either of its identifiers: the server name it is known by
* (what the user types, e.g. `element.io`) or the base url its client API lives at (e.g. `https://element.ems.host`).
*/
fun matches(other: AccountProvider): Boolean {
return serverNameOrBaseUrl() == other.serverNameOrBaseUrl()
}
/**
* Sanitizes the string with the following rules:
* - Trim any whitespace.
* - Lowercase the string.
* - Removes a https scheme (treating it as implicit).
* - Remove any trailing slashes.
*/
private fun String.sanitized(): String {
return trim()
.lowercase()
// Intentionally continue to show http:// as an indicator (and for history).
.removePrefix("https://")
.removeSuffix("/")
}
}
val matrixOrgAccountProvider = AccountProvider.Generic(
serverName = "matrix.org",
)

Some files were not shown because too many files have changed in this diff Show More