mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge branch 'develop' into feature/fga/timeline-multi-forward
This commit is contained in:
+1
@@ -34,6 +34,7 @@ class AccountSelectPresenter(
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
avatarThumbnail = it.userAvatarData,
|
||||
)
|
||||
}
|
||||
.toImmutableList()
|
||||
|
||||
+2
@@ -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",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
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.
+5
-4
@@ -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
-10
@@ -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
-13
@@ -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) }
|
||||
}
|
||||
|
||||
+7
-9
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+8
-9
@@ -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(
|
||||
|
||||
+9
-10
@@ -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))
|
||||
|
||||
+15
-16
@@ -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
-9
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+7
-9
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+8
-10
@@ -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(
|
||||
|
||||
+10
-12
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -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,
|
||||
)
|
||||
}
|
||||
-19
@@ -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)
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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)
|
||||
}
|
||||
|
||||
+7
-7
@@ -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
|
||||
)
|
||||
}
|
||||
+50
-36
@@ -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)
|
||||
|
||||
+28
-26
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -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
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+1
@@ -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)
|
||||
}
|
||||
|
||||
+51
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+72
@@ -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
|
||||
}
|
||||
+159
@@ -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)
|
||||
}
|
||||
+158
-33
@@ -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 {
|
||||
|
||||
+5
-2
@@ -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(),
|
||||
)
|
||||
|
||||
+60
-27
@@ -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 = { },
|
||||
)
|
||||
|
||||
+134
@@ -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?,
|
||||
)
|
||||
}
|
||||
+280
@@ -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)
|
||||
}
|
||||
}
|
||||
+127
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
+9
-7
@@ -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)
|
||||
}
|
||||
+41
@@ -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"
|
||||
}
|
||||
}
|
||||
+42
@@ -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
|
||||
}
|
||||
+107
@@ -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
|
||||
+20
@@ -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,
|
||||
)
|
||||
+70
@@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+606
@@ -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
|
||||
+262
@@ -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)
|
||||
}
|
||||
+259
@@ -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_"
|
||||
+223
@@ -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))
|
||||
}
|
||||
+6
-12
@@ -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)
|
||||
}
|
||||
+19
@@ -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)
|
||||
}
|
||||
+5
@@ -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.
|
||||
|
||||
+73
@@ -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
Reference in New Issue
Block a user