handling more text parsing cases for fallbacks and urls

This commit is contained in:
Adam Brown 2022-10-24 22:03:53 +01:00
parent fddcdaa50c
commit 9476fc5814
6 changed files with 112 additions and 24 deletions

View File

@ -7,6 +7,7 @@ data class RichText(val parts: Set<Part>) {
data class Bold(val content: String) : Part data class Bold(val content: String) : Part
data class Italic(val content: String) : Part data class Italic(val content: String) : Part
data class BoldItalic(val content: String) : Part data class BoldItalic(val content: String) : Part
data class Person(val userId: String) : Part
} }
} }

View File

@ -17,14 +17,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.*
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -265,6 +262,10 @@ val hyperLinkStyle = SpanStyle(
textDecoration = TextDecoration.Underline textDecoration = TextDecoration.Underline
) )
val nameStyle = SpanStyle(
color = Color(0xff64B5F6),
)
fun RichText.toAnnotatedText() = buildAnnotatedString { fun RichText.toAnnotatedText() = buildAnnotatedString {
parts.forEach { parts.forEach {
when (it) { when (it) {
@ -278,6 +279,9 @@ fun RichText.toAnnotatedText() = buildAnnotatedString {
} }
is RichText.Part.Normal -> append(it.content) is RichText.Part.Normal -> append(it.content)
is RichText.Part.Person -> withStyle(nameStyle) {
append("@${it.userId.substringBefore(':').removePrefix("@")}")
}
} }
} }
} }
@ -295,4 +299,4 @@ private fun AuthorName(event: BubbleModel.Event, bubble: BubbleMeta) {
@Composable @Composable
private fun BubbleMeta.textColor(): Color { private fun BubbleMeta.textColor(): Color {
return if (this.isSelf) SmallTalkTheme.extendedColors.onSelfBubble else SmallTalkTheme.extendedColors.onOthersBubble return if (this.isSelf) SmallTalkTheme.extendedColors.onSelfBubble else SmallTalkTheme.extendedColors.onOthersBubble
} }

View File

@ -234,6 +234,7 @@ private fun RichText.toApp(): app.dapk.st.core.RichText {
is RichText.Part.Italic -> app.dapk.st.core.RichText.Part.Italic(it.content) is RichText.Part.Italic -> app.dapk.st.core.RichText.Part.Italic(it.content)
is RichText.Part.Link -> app.dapk.st.core.RichText.Part.Link(it.url, it.label) is RichText.Part.Link -> app.dapk.st.core.RichText.Part.Link(it.url, it.label)
is RichText.Part.Normal -> app.dapk.st.core.RichText.Part.Normal(it.content) is RichText.Part.Normal -> app.dapk.st.core.RichText.Part.Normal(it.content)
is RichText.Part.Person -> app.dapk.st.core.RichText.Part.Person(it.userId.value)
} }
}.toSet()) }.toSet())
} }

View File

@ -21,6 +21,9 @@ data class RichText(@SerialName("parts") val parts: Set<Part>) {
@Serializable @Serializable
data class BoldItalic(@SerialName("content") val content: String) : Part data class BoldItalic(@SerialName("content") val content: String) : Part
@Serializable
data class Person(@SerialName("user_id") val userId: UserId, @SerialName("display_name") val displayName: String) : Part
} }
companion object { companion object {
@ -35,5 +38,6 @@ fun RichText.asString() = parts.joinToString(separator = "") {
is RichText.Part.Italic -> it.content is RichText.Part.Italic -> it.content
is RichText.Part.Link -> it.label is RichText.Part.Link -> it.label
is RichText.Part.Normal -> it.content is RichText.Part.Normal -> it.content
is RichText.Part.Person -> it.userId.value
} }
} }

View File

@ -2,10 +2,14 @@ package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.RichText import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.common.RichText.Part.* import app.dapk.st.matrix.common.RichText.Part.*
import app.dapk.st.matrix.common.UserId
class RichMessageParser { class RichMessageParser {
fun parse(input: String): RichText { fun parse(source: String): RichText {
val input = source
.removeHtmlEntities()
.dropTextFallback()
return kotlin.runCatching { return kotlin.runCatching {
val buffer = mutableSetOf<RichText.Part>() val buffer = mutableSetOf<RichText.Part>()
var openIndex = 0 var openIndex = 0
@ -21,12 +25,23 @@ class RichMessageParser {
val wholeTag = input.substring(foundIndex, closeIndex + 1) val wholeTag = input.substring(foundIndex, closeIndex + 1)
val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' }) val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' })
if (tagName.startsWith('@')) {
if (openIndex != foundIndex) {
buffer.add(Normal(input.substring(openIndex, foundIndex)))
}
buffer.add(Person(UserId(tagName), tagName))
println(tagName)
openIndex = foundIndex + wholeTag.length
lastStartIndex = openIndex
continue
}
if (tagName == "br") { if (tagName == "br") {
if (openIndex != foundIndex) { if (openIndex != foundIndex) {
buffer.add(Normal(input.substring(openIndex, foundIndex))) buffer.add(Normal(input.substring(openIndex, foundIndex)))
} }
buffer.add(Normal("\n")) buffer.add(Normal("\n"))
openIndex = foundIndex + "<br />".length openIndex = foundIndex + wholeTag.length
lastStartIndex = openIndex lastStartIndex = openIndex
continue continue
} }
@ -39,6 +54,14 @@ class RichMessageParser {
if (exitIndex == -1) { if (exitIndex == -1) {
openIndex++ openIndex++
} else { } else {
when (tagName) {
"mx-reply" -> {
openIndex = exitIndex + exitTag.length
lastStartIndex = openIndex
continue
}
}
if (openIndex != foundIndex) { if (openIndex != foundIndex) {
buffer.add(Normal(input.substring(openIndex, foundIndex))) buffer.add(Normal(input.substring(openIndex, foundIndex)))
} }
@ -49,7 +72,16 @@ class RichMessageParser {
when (tagName) { when (tagName) {
"a" -> { "a" -> {
val findHrefUrl = wholeTag.substringAfter("href=").replace("\"", "").removeSuffix(">") val findHrefUrl = wholeTag.substringAfter("href=").replace("\"", "").removeSuffix(">")
buffer.add(Link(url = findHrefUrl, label = tagContent)) if (findHrefUrl.startsWith("https://matrix.to/#/@")) {
val userId = UserId(findHrefUrl.substringAfter("https://matrix.to/#/").substringBeforeLast("\""))
buffer.add(Person(userId, "@${tagContent.removePrefix("@")}"))
if (input.getOrNull(openIndex) == ':') {
openIndex++
lastStartIndex = openIndex
}
} else {
buffer.add(Link(url = findHrefUrl, label = tagContent))
}
} }
"b" -> buffer.add(Bold(tagContent)) "b" -> buffer.add(Bold(tagContent))
@ -84,12 +116,6 @@ class RichMessageParser {
else -> { else -> {
val substring = input.substring(urlIndex, urlEndIndex) val substring = input.substring(urlIndex, urlEndIndex)
val last = substring.last()
if (last == '.' || last == ',') {
substring.dropLast(1)
}
val url = substring.removeSuffix(".").removeSuffix(",") val url = substring.removeSuffix(".").removeSuffix(",")
buffer.add(Link(url = url, label = url)) buffer.add(Link(url = url, label = url))
openIndex = if (substring.endsWith('.') || substring.endsWith(',')) urlEndIndex - 1 else urlEndIndex openIndex = if (substring.endsWith('.') || substring.endsWith(',')) urlEndIndex - 1 else urlEndIndex
@ -114,3 +140,7 @@ class RichMessageParser {
} }
} }
private fun String.removeHtmlEntities() = this.replace("&quot;", "\"").replace("&#39;", "'")
private fun String.dropTextFallback() = this.lines().dropWhile { it.startsWith("> ") || it.isEmpty() }.joinToString("")

View File

@ -1,8 +1,8 @@
package app.dapk.st.matrix.sync.internal.sync package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.RichText import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.common.RichText.Part.Link import app.dapk.st.matrix.common.RichText.Part.*
import app.dapk.st.matrix.common.RichText.Part.Normal import fixture.aUserId
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.junit.Ignore import org.junit.Ignore
import org.junit.Test import org.junit.Test
@ -23,6 +23,30 @@ class RichMessageParserTest {
expected = RichText(setOf(Normal("Hello world! "), Normal("foo bar"), Normal(" after paragraph"))) expected = RichText(setOf(Normal("Hello world! "), Normal("foo bar"), Normal(" after paragraph")))
) )
@Test
fun `replaces quote entity`() = runParserTest(
input = "Hello world! &quot;foo bar&quot;",
expected = RichText(setOf(Normal("Hello world! \"foo bar\"")))
)
@Test
fun `replaces apostrophe entity`() = runParserTest(
input = "Hello world! foo&#39;s bar",
expected = RichText(setOf(Normal("Hello world! foo's bar")))
)
@Test
fun `replaces people`() = runParserTest(
input = "Hello <@my-name:a-domain.foo>!",
expected = RichText(setOf(Normal("Hello "), Person(aUserId("@my-name:a-domain.foo"), "@my-name:a-domain.foo"), Normal("!")))
)
@Test
fun `replaces matrixdotto with person`() = runParserTest(
input = """Hello <a href="https://matrix.to/#/@a-name:foo.bar>a-name</a>: world""",
expected = RichText(setOf(Normal("Hello "), Person(aUserId("@a-name:foo.bar"), "@a-name"), Normal(" world")))
)
@Test @Test
fun `skips header tags`() = runParserTest( fun `skips header tags`() = runParserTest(
Case( Case(
@ -62,15 +86,39 @@ class RichMessageParserTest {
) )
@Test @Test
fun `parses styling text`() = runParserTest( fun `removes reply fallback`() = runParserTest(
input = "<em>hello</em> <strong>world</strong>", input = """
expected = RichText(setOf(RichText.Part.Italic("hello"), Normal(" "), RichText.Part.Bold("world"))) <mx-reply>
<blockquote>
Original message
</blockquote>
</mx-reply>
Reply to message
""".trimIndent(),
expected = RichText(setOf(Normal("Reply to message")))
) )
@Test @Test
fun `parses raw reply text`() = runParserTest( fun `removes text fallback`() = runParserTest(
input = "> <@a-matrix-id:domain.foo> This is a reply", input = """
expected = RichText(setOf(Normal("> <@a-matrix-id:domain.foo> This is a reply"))) > <@user:domain.foo> Original message
> Some more content
Reply to message
""".trimIndent(),
expected = RichText(setOf(Normal("Reply to message")))
)
@Test
fun `parses styling text`() = runParserTest(
input = "<em>hello</em> <strong>world</strong>",
expected = RichText(setOf(Italic("hello"), Normal(" "), Bold("world")))
)
@Test
fun `parses invalid tags text`() = runParserTest(
input = ">><foo> ><>> << more content",
expected = RichText(setOf(Normal(">><foo> ><>> << more content")))
) )
@Test @Test
@ -80,7 +128,7 @@ class RichMessageParserTest {
expected = RichText( expected = RichText(
setOf( setOf(
Normal("hello "), Normal("hello "),
RichText.Part.Bold("wor"), Bold("wor"),
Normal("ld"), Normal("ld"),
) )
) )
@ -94,7 +142,7 @@ class RichMessageParserTest {
expected = RichText( expected = RichText(
setOf( setOf(
Normal("hello "), Normal("hello "),
RichText.Part.Italic("wor"), Italic("wor"),
Normal("ld"), Normal("ld"),
) )
) )