Browse Source

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.
pull/1871/head
Jorge Martin Espinosa 10 months ago committed by GitHub
parent
commit
d413aa1ee3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      changelog.d/1850.feature
  2. 4
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
  3. 40
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
  4. 15
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
  5. 3
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
  6. 52
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt
  7. 3
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
  8. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt
  9. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt
  10. 1
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt
  11. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt
  12. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt
  13. 4
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt
  14. 102
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/model/InReplyToDetailTest.kt
  15. 1
      libraries/eventformatter/impl/build.gradle.kts
  16. 3
      libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
  17. 1
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
  18. 1
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
  19. 5
      libraries/matrixui/build.gradle.kts
  20. 31
      libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt
  21. 82
      libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt
  22. 98
      libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt
  23. 122
      libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt
  24. 6
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt

1
changelog.d/1850.feature

@ -0,0 +1 @@ @@ -0,0 +1 @@
Add plain text representation of messages

4
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
@ -34,7 +35,6 @@ import io.element.android.libraries.matrix.api.core.TransactionId @@ -34,7 +35,6 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -122,7 +122,7 @@ internal fun aTimelineItemEvent( @@ -122,7 +122,7 @@ internal fun aTimelineItemEvent(
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState? = null,
inReplyTo: InReplyTo? = null,
inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),

40
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt

@ -68,6 +68,7 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel @@ -68,6 +68,7 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
@ -97,7 +98,6 @@ import io.element.android.libraries.matrix.api.core.UserId @@ -97,7 +98,6 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
@ -142,7 +142,7 @@ fun TimelineItemEventRow( @@ -142,7 +142,7 @@ fun TimelineItemEventRow(
}
fun inReplyToClicked() {
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
val inReplyToEventId = event.inReplyTo?.eventId ?: return
inReplyToClick(inReplyToEventId)
}
@ -497,7 +497,7 @@ private fun MessageEventBubbleContent( @@ -497,7 +497,7 @@ private fun MessageEventBubbleContent(
fun CommonLayout(
timestampPosition: TimestampPosition,
showThreadDecoration: Boolean,
inReplyToDetails: InReplyTo.Ready?,
inReplyToDetails: InReplyToDetails?,
modifier: Modifier = Modifier
) {
val timestampLayoutModifier: Modifier
@ -543,10 +543,10 @@ private fun MessageEventBubbleContent( @@ -543,10 +543,10 @@ private fun MessageEventBubbleContent(
)
}
}
val inReplyTo = @Composable { inReplyToReady: InReplyTo.Ready ->
val senderName = inReplyToReady.senderDisplayName ?: inReplyToReady.senderId.value
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToReady)
val text = textForInReplyTo(inReplyToReady)
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
val senderName = inReplyTo.senderDisplayName ?: inReplyTo.senderId.value
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyTo)
val text = textForInReplyTo(inReplyTo)
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
ReplyToContent(
senderName = senderName,
@ -581,11 +581,10 @@ private fun MessageEventBubbleContent( @@ -581,11 +581,10 @@ private fun MessageEventBubbleContent(
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
}
val replyToDetails = event.inReplyTo as? InReplyTo.Ready
CommonLayout(
showThreadDecoration = event.isThreaded,
timestampPosition = timestampPosition,
inReplyToDetails = replyToDetails,
inReplyToDetails = event.inReplyTo,
modifier = bubbleModifier
)
}
@ -638,8 +637,8 @@ private fun ReplyToContent( @@ -638,8 +637,8 @@ private fun ReplyToContent(
}
}
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready): AttachmentThumbnailInfo? {
return when (val eventContent = inReplyTo.content) {
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyToDetails): AttachmentThumbnailInfo? {
return when (val eventContent = inReplyTo.eventContent) {
is MessageContent -> when (val type = eventContent.type) {
is ImageMessageType -> AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource ?: type.source,
@ -680,12 +679,12 @@ private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready): Att @@ -680,12 +679,12 @@ private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready): Att
}
@Composable
private fun textForInReplyTo(inReplyTo: InReplyTo.Ready): String {
return when (val eventContent = inReplyTo.content) {
private fun textForInReplyTo(inReplyTo: InReplyToDetails): String {
return when (val eventContent = inReplyTo.eventContent) {
is MessageContent -> when (eventContent.type) {
is LocationMessageType -> stringResource(CommonStrings.common_shared_location)
is VoiceMessageType -> stringResource(CommonStrings.common_voice_message)
else -> eventContent.body
else -> inReplyTo.textContent ?: eventContent.body
}
is PollContent -> eventContent.question
else -> ""
@ -769,7 +768,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview { @@ -769,7 +768,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors."
),
inReplyTo = aInReplyToReady(replyContent),
inReplyTo = aInReplyToDetails(replyContent),
groupPosition = TimelineItemGroupPosition.First,
),
showReadReceipts = false,
@ -794,7 +793,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview { @@ -794,7 +793,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
content = aTimelineItemImageContent().copy(
aspectRatio = 5f
),
inReplyTo = aInReplyToReady(replyContent),
inReplyTo = aInReplyToDetails(replyContent),
isThreaded = true,
groupPosition = TimelineItemGroupPosition.Last,
),
@ -818,15 +817,16 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview { @@ -818,15 +817,16 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
}
}
private fun aInReplyToReady(
private fun aInReplyToDetails(
replyContent: String,
): InReplyTo.Ready {
return InReplyTo.Ready(
): InReplyToDetails {
return InReplyToDetails(
eventId = EventId("\$event"),
content = MessageContent(replyContent, null, false, false, TextMessageType(replyContent, null)),
eventContent = MessageContent(replyContent, null, false, false, TextMessageType(replyContent, null)),
senderId = UserId("@Sender:domain"),
senderDisplayName = "Sender",
senderAvatarUrl = null,
textContent = replyContent,
)
}

15
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt

@ -28,7 +28,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -28,7 +28,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -46,6 +45,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy @@ -46,6 +45,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.time.Duration
@ -85,6 +85,7 @@ class TimelineItemContentMessageFactory @Inject constructor( @@ -85,6 +85,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
TimelineItemTextContent(
body = messageType.body,
htmlDocument = null,
plainText = messageType.body,
isEdited = content.isEdited,
)
} else {
@ -161,11 +162,13 @@ class TimelineItemContentMessageFactory @Inject constructor( @@ -161,11 +162,13 @@ class TimelineItemContentMessageFactory @Inject constructor(
htmlDocument = messageType.formatted?.toHtmlDocument(),
isEdited = content.isEdited,
)
is TextMessageType -> TimelineItemTextContent(
body = messageType.body,
htmlDocument = messageType.formatted?.toHtmlDocument(),
isEdited = content.isEdited,
)
is TextMessageType -> {
TimelineItemTextContent(
body = messageType.body,
htmlDocument = messageType.formatted?.toHtmlDocument(),
isEdited = content.isEdited,
)
}
is OtherMessageType -> TimelineItemTextContent(
body = messageType.body,
htmlDocument = null,

3
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt

@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.map
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -92,7 +93,7 @@ class TimelineItemEventFactory @Inject constructor( @@ -92,7 +93,7 @@ class TimelineItemEventFactory @Inject constructor(
reactionsState = currentTimelineItem.computeReactionsState(),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo(),
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(),
isThreaded = currentTimelineItem.event.isThreaded(),
debugInfo = currentTimelineItem.event.debugInfo,
origin = currentTimelineItem.event.origin,

52
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt

@ -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
}

3
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt

@ -25,7 +25,6 @@ import io.element.android.libraries.matrix.api.core.EventId @@ -25,7 +25,6 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import kotlinx.collections.immutable.ImmutableList
@ -67,7 +66,7 @@ sealed interface TimelineItem { @@ -67,7 +66,7 @@ sealed interface TimelineItem {
val reactionsState: TimelineItemReactions,
val readReceiptState: TimelineItemReadReceipts,
val localSendState: LocalEventSendState?,
val inReplyTo: InReplyTo?,
val inReplyTo: InReplyToDetails?,
val isThreaded: Boolean,
val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?,

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt

@ -16,11 +16,13 @@ @@ -16,11 +16,13 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.ui.messages.toPlainText
import org.jsoup.nodes.Document
data class TimelineItemEmoteContent(
override val body: String,
override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemEmoteContent"

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt

@ -16,11 +16,13 @@ @@ -16,11 +16,13 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.ui.messages.toPlainText
import org.jsoup.nodes.Document
data class TimelineItemNoticeContent(
override val body: String,
override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemNoticeContent"

1
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt

@ -23,6 +23,7 @@ import org.jsoup.nodes.Document @@ -23,6 +23,7 @@ import org.jsoup.nodes.Document
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
val body: String
val htmlDocument: Document?
val plainText: String
val isEdited: Boolean
val htmlBody: String?
get() = htmlDocument?.body()?.html()

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt

@ -16,11 +16,13 @@ @@ -16,11 +16,13 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.ui.messages.toPlainText
import org.jsoup.nodes.Document
data class TimelineItemTextContent(
override val body: String,
override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent{
override val type: String = "TimelineItemTextContent"

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt

@ -43,7 +43,7 @@ class MessageSummaryFormatterImpl @Inject constructor( @@ -43,7 +43,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
) : MessageSummaryFormatter {
override fun format(event: TimelineItem.Event): String {
return when (event.content) {
is TimelineItemTextBasedContent -> event.content.body
is TimelineItemTextBasedContent -> event.content.plainText
is TimelineItemProfileChangeContent -> event.content.body
is TimelineItemStateContent -> event.content.body
is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location)

4
features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
package io.element.android.features.messages.fixtures
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
@ -27,7 +28,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -27,7 +28,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID
@ -39,7 +39,7 @@ internal fun aMessageEvent( @@ -39,7 +39,7 @@ internal fun aMessageEvent(
eventId: EventId? = AN_EVENT_ID,
isMine: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
inReplyTo: InReplyTo? = null,
inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),

102
features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/model/InReplyToDetailTest.kt

@ -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!**")
}
}

1
libraries/eventformatter/impl/build.gradle.kts

@ -40,6 +40,7 @@ dependencies { @@ -40,6 +40,7 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
api(projects.libraries.eventformatter.api)

3
libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt

@ -51,6 +51,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UnknownConten @@ -51,6 +51,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UnknownConten
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
@ -114,7 +115,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor( @@ -114,7 +115,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
return "* $senderDisplayName ${messageType.body}"
}
is TextMessageType -> {
messageType.body
messageType.toPlainText()
}
is VideoMessageType -> {
sp.getString(CommonStrings.common_video)

1
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt

@ -36,6 +36,7 @@ data class NotificationData( @@ -36,6 +36,7 @@ data class NotificationData(
val content: NotificationContent,
// For images for instance
val contentUrl: String?,
val hasMention: Boolean,
)
sealed interface NotificationContent {

1
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt

@ -53,6 +53,7 @@ class NotificationMapper( @@ -53,6 +53,7 @@ class NotificationMapper(
timestamp = item.timestamp() ?: clock.epochMillis(),
content = item.event.use { notificationContentMapper.map(it) },
contentUrl = null,
hasMention = item.hasMention.orFalse(),
)
}
}

5
libraries/matrixui/build.gradle.kts

@ -40,6 +40,11 @@ dependencies { @@ -40,6 +40,11 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(libs.coil.compose)
implementation(libs.coil.gif)
implementation(libs.jsoup)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
}

31
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/toHtmlDocument.kt → libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt

@ -14,19 +14,46 @@ @@ -14,19 +14,46 @@
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.util
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 ->
if (prefix != null) {
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("@")
}
}
}
}

82
libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt

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

98
libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt

@ -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!")
}
}

122
libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt

@ -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")
}
}

6
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt

@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy @@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
@ -81,6 +82,7 @@ class NotifiableEventResolver @Inject constructor( @@ -81,6 +82,7 @@ class NotifiableEventResolver @Inject constructor(
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent? {
return when (val content = this.content) {
is NotificationContent.MessageLike.RoomMessage -> {
val messageBody = descriptionFromMessageContent(content, senderDisplayName ?: content.senderId.value)
buildNotifiableMessageEvent(
sessionId = userId,
senderId = content.senderId,
@ -89,7 +91,7 @@ class NotifiableEventResolver @Inject constructor( @@ -89,7 +91,7 @@ class NotifiableEventResolver @Inject constructor(
noisy = isNoisy,
timestamp = this.timestamp,
senderName = senderDisplayName,
body = descriptionFromMessageContent(content, senderDisplayName ?: content.senderId.value),
body = messageBody,
imageUriString = this.contentUrl,
roomName = roomDisplayName,
roomIsDirect = isDirect,
@ -216,7 +218,7 @@ class NotifiableEventResolver @Inject constructor( @@ -216,7 +218,7 @@ class NotifiableEventResolver @Inject constructor(
is FileMessageType -> messageType.body
is ImageMessageType -> messageType.body
is NoticeMessageType -> messageType.body
is TextMessageType -> messageType.body
is TextMessageType -> messageType.toPlainText()
is VideoMessageType -> messageType.body
is LocationMessageType -> messageType.body
is OtherMessageType -> messageType.body

Loading…
Cancel
Save