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()
}
}