diff --git a/core/src/main/kotlin/app/dapk/st/core/RichText.kt b/core/src/main/kotlin/app/dapk/st/core/RichText.kt index ee72339..8cff054 100644 --- a/core/src/main/kotlin/app/dapk/st/core/RichText.kt +++ b/core/src/main/kotlin/app/dapk/st/core/RichText.kt @@ -7,6 +7,7 @@ data class RichText(val parts: Set) { data class Bold(val content: String) : Part data class Italic(val content: String) : Part data class BoldItalic(val content: String) : Part + data class Person(val userId: String) : Part } } diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt index 46335b2..91506a4 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt @@ -17,14 +17,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -265,6 +262,10 @@ val hyperLinkStyle = SpanStyle( textDecoration = TextDecoration.Underline ) +val nameStyle = SpanStyle( + color = Color(0xff64B5F6), +) + fun RichText.toAnnotatedText() = buildAnnotatedString { parts.forEach { when (it) { @@ -278,6 +279,9 @@ fun RichText.toAnnotatedText() = buildAnnotatedString { } 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 private fun BubbleMeta.textColor(): Color { return if (this.isSelf) SmallTalkTheme.extendedColors.onSelfBubble else SmallTalkTheme.extendedColors.onOthersBubble -} \ No newline at end of file +} diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index 843b7ee..a19c109 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -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.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.Person -> app.dapk.st.core.RichText.Part.Person(it.userId.value) } }.toSet()) } diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt index 148ea81..b6f624c 100644 --- a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt @@ -21,6 +21,9 @@ data class RichText(@SerialName("parts") val parts: Set) { @Serializable 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 { @@ -35,5 +38,6 @@ fun RichText.asString() = parts.joinToString(separator = "") { is RichText.Part.Italic -> it.content is RichText.Part.Link -> it.label is RichText.Part.Normal -> it.content + is RichText.Part.Person -> it.userId.value } } \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt index 2417899..e65e9b4 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt @@ -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.Part.* +import app.dapk.st.matrix.common.UserId class RichMessageParser { - fun parse(input: String): RichText { + fun parse(source: String): RichText { + val input = source + .removeHtmlEntities() + .dropTextFallback() return kotlin.runCatching { val buffer = mutableSetOf() var openIndex = 0 @@ -21,12 +25,23 @@ class RichMessageParser { val wholeTag = input.substring(foundIndex, closeIndex + 1) 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 (openIndex != foundIndex) { buffer.add(Normal(input.substring(openIndex, foundIndex))) } buffer.add(Normal("\n")) - openIndex = foundIndex + "
".length + openIndex = foundIndex + wholeTag.length lastStartIndex = openIndex continue } @@ -39,6 +54,14 @@ class RichMessageParser { if (exitIndex == -1) { openIndex++ } else { + when (tagName) { + "mx-reply" -> { + openIndex = exitIndex + exitTag.length + lastStartIndex = openIndex + continue + } + } + if (openIndex != foundIndex) { buffer.add(Normal(input.substring(openIndex, foundIndex))) } @@ -49,7 +72,16 @@ class RichMessageParser { when (tagName) { "a" -> { 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)) @@ -84,12 +116,6 @@ class RichMessageParser { else -> { val substring = input.substring(urlIndex, urlEndIndex) - - val last = substring.last() - if (last == '.' || last == ',') { - substring.dropLast(1) - } - val url = substring.removeSuffix(".").removeSuffix(",") buffer.add(Link(url = url, label = url)) openIndex = if (substring.endsWith('.') || substring.endsWith(',')) urlEndIndex - 1 else urlEndIndex @@ -114,3 +140,7 @@ class RichMessageParser { } } + +private fun String.removeHtmlEntities() = this.replace(""", "\"").replace("'", "'") + +private fun String.dropTextFallback() = this.lines().dropWhile { it.startsWith("> ") || it.isEmpty() }.joinToString("") diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt index 670192c..22f122d 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt @@ -1,8 +1,8 @@ package app.dapk.st.matrix.sync.internal.sync import app.dapk.st.matrix.common.RichText -import app.dapk.st.matrix.common.RichText.Part.Link -import app.dapk.st.matrix.common.RichText.Part.Normal +import app.dapk.st.matrix.common.RichText.Part.* +import fixture.aUserId import org.amshove.kluent.shouldBeEqualTo import org.junit.Ignore import org.junit.Test @@ -23,6 +23,30 @@ class RichMessageParserTest { expected = RichText(setOf(Normal("Hello world! "), Normal("foo bar"), Normal(" after paragraph"))) ) + @Test + fun `replaces quote entity`() = runParserTest( + input = "Hello world! "foo bar"", + expected = RichText(setOf(Normal("Hello world! \"foo bar\""))) + ) + + @Test + fun `replaces apostrophe entity`() = runParserTest( + input = "Hello world! foo'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 +
+ Original message +
+ + Reply to message + """.trimIndent(), + expected = RichText(setOf(Normal("Reply to message"))) ) @Test - fun `parses raw reply text`() = runParserTest( - input = "> <@a-matrix-id:domain.foo> This is a reply", - expected = RichText(setOf(Normal("> <@a-matrix-id:domain.foo> This is a reply"))) + fun `removes text fallback`() = runParserTest( + input = """ + > <@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 = "hello world", + expected = RichText(setOf(Italic("hello"), Normal(" "), Bold("world"))) + ) + + @Test + fun `parses invalid tags text`() = runParserTest( + input = ">> ><>> << more content", + expected = RichText(setOf(Normal(">> ><>> << more content"))) ) @Test @@ -80,7 +128,7 @@ class RichMessageParserTest { expected = RichText( setOf( Normal("hello "), - RichText.Part.Bold("wor"), + Bold("wor"), Normal("ld"), ) ) @@ -94,7 +142,7 @@ class RichMessageParserTest { expected = RichText( setOf( Normal("hello "), - RichText.Part.Italic("wor"), + Italic("wor"), Normal("ld"), ) )