Add plain text representation of messages (#1850)

* Add plain text representation of messages.

This is used in the room list as the last message in a room, in the message summary when a message is selected, in the 'replying to' block, in the 'replied to' block in a message in the timeline, and in notifications.
This commit is contained in:
Jorge Martin Espinosa 2023-11-23 08:29:20 +01:00 committed by GitHub
parent e13b204f4b
commit d413aa1ee3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 544 additions and 39 deletions

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.ui.messages
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
/**
* Converts the HTML string [FormattedBody.body] to a [Document] by parsing it.
* If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`.
*
* This will also make sure mentions are prefixed with `@`.
*
* @param prefix if not null, the prefix will be inserted at the beginning of the message.
*/
fun FormattedBody.toHtmlDocument(prefix: String? = null): Document? {
return takeIf { it.format == MessageFormat.HTML }?.body?.let { formattedBody ->
val dom = if (prefix != null) {
Jsoup.parse("$prefix $formattedBody")
} else {
Jsoup.parse(formattedBody)
}
// Prepend `@` to mentions
fixMentions(dom)
dom
}
}
private fun fixMentions(dom: Document) {
val links = dom.getElementsByTag("a")
links.forEach {
if (it.hasAttr("href")) {
val link = PermalinkParser.parse(it.attr("href"))
if (link is PermalinkData.UserLink && !it.text().startsWith("@")) {
it.prependText("@")
}
}
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.ui.messages
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import org.jsoup.select.NodeVisitor
/**
* Converts the HTML string in [TextMessageType.formatted] to a plain text representation by parsing it and removing all formatting.
* If the message is not formatted or the format is not [MessageFormat.HTML], the [TextMessageType.body] is returned instead.
*/
fun TextMessageType.toPlainText() = formatted?.toPlainText() ?: body
/**
* Converts the HTML string in [FormattedBody.body] to a plain text representation by parsing it and removing all formatting.
* If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`.
* @param prefix if not null, the prefix will be inserted at the beginning of the message.
*/
fun FormattedBody.toPlainText(prefix: String? = null): String? {
return this.toHtmlDocument(prefix)?.toPlainText()
}
/**
* Converts the HTML [Document] to a plain text representation by parsing it and removing all formatting.
*/
fun Document.toPlainText(): String {
val visitor = PlainTextNodeVisitor()
traverse(visitor)
return visitor.build()
}
private class PlainTextNodeVisitor : NodeVisitor {
private val builder = StringBuilder()
override fun head(node: Node, depth: Int) {
if (node is TextNode && node.text().isNotBlank()) {
builder.append(node.text())
} else if (node is Element && node.tagName() == "li") {
val index = node.elementSiblingIndex()
val isOrdered = node.parent()?.nodeName()?.lowercase() == "ol"
if (isOrdered) {
builder.append("${index + 1}. ")
} else {
builder.append("")
}
} else if (node is Element && node.isBlock && builder.lastOrNull() != '\n') {
builder.append("\n")
}
}
override fun tail(node: Node, depth: Int) {
fun nodeIsBlockButNotLastOne(node: Node) = node is Element && node.isBlock && node.lastElementSibling() !== node
fun nodeIsLineBreak(node: Node) = node.nodeName().lowercase() == "br"
if (nodeIsBlockButNotLastOne(node) || nodeIsLineBreak(node)) {
builder.append("\n")
}
}
fun build(): String {
return builder.toString().trim()
}
}

View file

@ -0,0 +1,98 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrixui.messages
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class ToHtmlDocumentTest {
@Test
fun `toHtmlDocument - returns null if format is not HTML`() {
val body = FormattedBody(
format = MessageFormat.UNKNOWN,
body = "Hello world"
)
val document = body.toHtmlDocument()
assertThat(document).isNull()
}
@Test
fun `toHtmlDocument - returns a Document if the format is HTML`() {
val body = FormattedBody(
format = MessageFormat.HTML,
body = "<p>Hello world</p>"
)
val document = body.toHtmlDocument()
assertThat(document).isNotNull()
assertThat(document?.text()).isEqualTo("Hello world")
}
@Test
fun `toHtmlDocument - returns a Document with a prefix if provided`() {
val body = FormattedBody(
format = MessageFormat.HTML,
body = "<p>Hello world</p>"
)
val document = body.toHtmlDocument(prefix = "@Jorge:")
assertThat(document).isNotNull()
assertThat(document?.text()).isEqualTo("@Jorge: Hello world")
}
@Test
fun `toHtmlDocument - if a mention is found without an '@' prefix, it will be added`() {
val body = FormattedBody(
format = MessageFormat.HTML,
body = "Hey <a href='https://matrix.to/#/@alice:matrix.org'>Alice</a>!"
)
val document = body.toHtmlDocument()
assertThat(document?.text()).isEqualTo("Hey @Alice!")
}
@Test
fun `toHtmlDocument - if a mention is found with an '@' prefix, nothing will be done`() {
val body = FormattedBody(
format = MessageFormat.HTML,
body = "Hey <a href='https://matrix.to/#/@alice:matrix.org'>@Alice</a>!"
)
val document = body.toHtmlDocument()
assertThat(document?.text()).isEqualTo("Hey @Alice!")
}
@Test
fun `toHtmlDocument - if a link is not a mention, nothing will be done for it`() {
val body = FormattedBody(
format = MessageFormat.HTML,
body = "Hey <a href='https://matrix.org'>Alice</a>!"
)
val document = body.toHtmlDocument()
assertThat(document?.text()).isEqualTo("Hey Alice!")
}
}

View file

@ -0,0 +1,122 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrixui.messages
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText
import org.jsoup.Jsoup
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class ToPlainTextTest {
@Test
fun `Document toPlainText - returns a plain text version of the document`() {
val document = Jsoup.parse(
"""
Hello world
<ul><li>This is an unordered list.</li></ul>
<ol><li>This is an ordered list.</li></ol>
<br />
""".trimIndent()
)
assertThat(document.toPlainText()).isEqualTo("""
Hello world
This is an unordered list.
1. This is an ordered list.
""".trimIndent()
)
}
@Test
fun `FormattedBody toPlainText - returns a plain text version of the HTML body`() {
val formattedBody = FormattedBody(
format = MessageFormat.HTML,
body = """
Hello world
<ul><li>This is an unordered list.</li></ul>
<ol><li>This is an ordered list.</li></ol>
<br />
""".trimIndent()
)
assertThat(formattedBody.toPlainText()).isEqualTo("""
Hello world
This is an unordered list.
1. This is an ordered list.
""".trimIndent()
)
}
@Test
fun `FormattedBody toPlainText - returns null if the format is not HTML`() {
val formattedBody = FormattedBody(
format = MessageFormat.UNKNOWN,
body = """
Hello world
<ul><li>This is an unordered list.</li></ul>
<ol><li>This is an ordered list.</li></ol>
<br />
""".trimIndent()
)
assertThat(formattedBody.toPlainText()).isNull()
}
@Test
fun `TextMessageType toPlainText - returns a plain text version of the HTML body`() {
val messageType = TextMessageType(
body = "Hello world\n- This in an unordered list.\n1. This is an ordered list.\n",
formatted = FormattedBody(
format = MessageFormat.HTML,
body = """
Hello world
<ul><li>This is an unordered list.</li></ul>
<ol><li>This is an ordered list.</li></ol>
<br />
""".trimIndent()
)
)
assertThat(messageType.toPlainText()).isEqualTo("""
Hello world
This is an unordered list.
1. This is an ordered list.
""".trimIndent()
)
}
@Test
fun `TextMessageType toPlainText - returns the markdown body if the formatted one cannot be parsed`() {
val messageType = TextMessageType(
body = "This is the fallback text",
formatted = FormattedBody(
format = MessageFormat.UNKNOWN,
body = """
Hello world
<ul><li>This is an unordered list.</li></ul>
<ol><li>This is an ordered list.</li></ol>
<br />
""".trimIndent()
)
)
assertThat(messageType.toPlainText()).isEqualTo("This is the fallback text")
}
}