Browse Source
* 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.pull/1871/head
Jorge Martin Espinosa
10 months ago
committed by
GitHub
24 changed files with 544 additions and 39 deletions
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
Add plain text representation of messages |
@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
|
||||
/* |
||||
* 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.features.messages.impl.timeline.model |
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId |
||||
import io.element.android.libraries.matrix.api.core.UserId |
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent |
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo |
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent |
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType |
||||
import io.element.android.libraries.matrix.ui.messages.toPlainText |
||||
|
||||
data class InReplyToDetails( |
||||
val eventId: EventId, |
||||
val senderId: UserId, |
||||
val senderDisplayName: String?, |
||||
val senderAvatarUrl: String?, |
||||
val eventContent: EventContent?, |
||||
val textContent: String?, |
||||
) |
||||
|
||||
fun InReplyTo.map() = when (this) { |
||||
is InReplyTo.Ready -> InReplyToDetails( |
||||
eventId = eventId, |
||||
senderId = senderId, |
||||
senderDisplayName = senderDisplayName, |
||||
senderAvatarUrl = senderAvatarUrl, |
||||
eventContent = content, |
||||
textContent = when (content) { |
||||
is MessageContent -> { |
||||
val messageContent = content as MessageContent |
||||
(messageContent.type as? TextMessageType)?.toPlainText() ?: messageContent.body |
||||
} |
||||
else -> null |
||||
} |
||||
) |
||||
else -> null |
||||
} |
@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
|
||||
/* |
||||
* 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.features.messages.timeline.model |
||||
|
||||
import com.google.common.truth.Truth.assertThat |
||||
import io.element.android.features.messages.impl.timeline.model.map |
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody |
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo |
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange |
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent |
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat |
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent |
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType |
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID |
||||
import io.element.android.libraries.matrix.test.A_USER_ID |
||||
import org.junit.Test |
||||
|
||||
class InReplyToDetailTest { |
||||
|
||||
@Test |
||||
fun `map - with a not ready InReplyTo does not work`() { |
||||
assertThat(InReplyTo.Pending.map()).isNull() |
||||
assertThat(InReplyTo.NotLoaded(AN_EVENT_ID).map()).isNull() |
||||
assertThat(InReplyTo.Error.map()).isNull() |
||||
} |
||||
|
||||
@Test |
||||
fun `map - with something other than a MessageContent has no textContent`() { |
||||
val inReplyTo = InReplyTo.Ready( |
||||
eventId = AN_EVENT_ID, |
||||
senderId = A_USER_ID, |
||||
senderDisplayName = "senderDisplayName", |
||||
senderAvatarUrl = "senderAvatarUrl", |
||||
content = RoomMembershipContent( |
||||
userId = A_USER_ID, |
||||
change = MembershipChange.INVITED, |
||||
) |
||||
) |
||||
val inReplyToDetails = inReplyTo.map() |
||||
assertThat(inReplyToDetails).isNotNull() |
||||
assertThat(inReplyToDetails?.textContent).isNull() |
||||
} |
||||
|
||||
@Test |
||||
fun `map - with a message content tries to use the formatted text if exists for its textContent`() { |
||||
val inReplyTo = InReplyTo.Ready( |
||||
eventId = AN_EVENT_ID, |
||||
senderId = A_USER_ID, |
||||
senderDisplayName = "senderDisplayName", |
||||
senderAvatarUrl = "senderAvatarUrl", |
||||
content = MessageContent( |
||||
body = "**Hello!**", |
||||
inReplyTo = null, |
||||
isEdited = false, |
||||
isThreaded = false, |
||||
type = TextMessageType( |
||||
body = "**Hello!**", |
||||
formatted = FormattedBody( |
||||
format = MessageFormat.HTML, |
||||
body = "<p><b>Hello!</b></p>" |
||||
) |
||||
) |
||||
) |
||||
) |
||||
assertThat(inReplyTo.map()?.textContent).isEqualTo("Hello!") |
||||
} |
||||
|
||||
@Test |
||||
fun `map - with a message content and no formatted body uses body as fallback for textContent`() { |
||||
val inReplyTo = InReplyTo.Ready( |
||||
eventId = AN_EVENT_ID, |
||||
senderId = A_USER_ID, |
||||
senderDisplayName = "senderDisplayName", |
||||
senderAvatarUrl = "senderAvatarUrl", |
||||
content = MessageContent( |
||||
body = "**Hello!**", |
||||
inReplyTo = null, |
||||
isEdited = false, |
||||
isThreaded = false, |
||||
type = TextMessageType( |
||||
body = "**Hello!**", |
||||
formatted = null, |
||||
) |
||||
) |
||||
) |
||||
assertThat(inReplyTo.map()?.textContent).isEqualTo("**Hello!**") |
||||
} |
||||
} |
@ -0,0 +1,82 @@
@@ -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 @@
@@ -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 @@
@@ -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…
Reference in new issue