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:
parent
e13b204f4b
commit
d413aa1ee3
24 changed files with 544 additions and 39 deletions
|
|
@ -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("@")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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!")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue