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
b1f2370e72
commit
f55d347387
24 changed files with 544 additions and 39 deletions
1
changelog.d/1850.feature
Normal file
1
changelog.d/1850.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Add plain text representation of messages
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline
|
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.ReadReceiptData
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
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.TimelineItemGroupPosition
|
||||||
|
|
@ -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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
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.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.LocalEventSendState
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
@ -122,7 +122,7 @@ internal fun aTimelineItemEvent(
|
||||||
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
||||||
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||||
sendState: LocalEventSendState? = null,
|
sendState: LocalEventSendState? = null,
|
||||||
inReplyTo: InReplyTo? = null,
|
inReplyTo: InReplyToDetails? = null,
|
||||||
isThreaded: Boolean = false,
|
isThreaded: Boolean = false,
|
||||||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||||
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
|
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
|
||||||
|
|
|
||||||
|
|
@ -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.event.toExtraPadding
|
||||||
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
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.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.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||||
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
|
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
|
||||||
|
|
@ -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.AudioMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
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.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.LocationMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||||
|
|
@ -142,7 +142,7 @@ fun TimelineItemEventRow(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun inReplyToClicked() {
|
fun inReplyToClicked() {
|
||||||
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
|
val inReplyToEventId = event.inReplyTo?.eventId ?: return
|
||||||
inReplyToClick(inReplyToEventId)
|
inReplyToClick(inReplyToEventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -497,7 +497,7 @@ private fun MessageEventBubbleContent(
|
||||||
fun CommonLayout(
|
fun CommonLayout(
|
||||||
timestampPosition: TimestampPosition,
|
timestampPosition: TimestampPosition,
|
||||||
showThreadDecoration: Boolean,
|
showThreadDecoration: Boolean,
|
||||||
inReplyToDetails: InReplyTo.Ready?,
|
inReplyToDetails: InReplyToDetails?,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val timestampLayoutModifier: Modifier
|
val timestampLayoutModifier: Modifier
|
||||||
|
|
@ -543,10 +543,10 @@ private fun MessageEventBubbleContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val inReplyTo = @Composable { inReplyToReady: InReplyTo.Ready ->
|
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
|
||||||
val senderName = inReplyToReady.senderDisplayName ?: inReplyToReady.senderId.value
|
val senderName = inReplyTo.senderDisplayName ?: inReplyTo.senderId.value
|
||||||
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToReady)
|
val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyTo)
|
||||||
val text = textForInReplyTo(inReplyToReady)
|
val text = textForInReplyTo(inReplyTo)
|
||||||
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
||||||
ReplyToContent(
|
ReplyToContent(
|
||||||
senderName = senderName,
|
senderName = senderName,
|
||||||
|
|
@ -581,11 +581,10 @@ private fun MessageEventBubbleContent(
|
||||||
is TimelineItemPollContent -> TimestampPosition.Below
|
is TimelineItemPollContent -> TimestampPosition.Below
|
||||||
else -> TimestampPosition.Default
|
else -> TimestampPosition.Default
|
||||||
}
|
}
|
||||||
val replyToDetails = event.inReplyTo as? InReplyTo.Ready
|
|
||||||
CommonLayout(
|
CommonLayout(
|
||||||
showThreadDecoration = event.isThreaded,
|
showThreadDecoration = event.isThreaded,
|
||||||
timestampPosition = timestampPosition,
|
timestampPosition = timestampPosition,
|
||||||
inReplyToDetails = replyToDetails,
|
inReplyToDetails = event.inReplyTo,
|
||||||
modifier = bubbleModifier
|
modifier = bubbleModifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -638,8 +637,8 @@ private fun ReplyToContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready): AttachmentThumbnailInfo? {
|
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyToDetails): AttachmentThumbnailInfo? {
|
||||||
return when (val eventContent = inReplyTo.content) {
|
return when (val eventContent = inReplyTo.eventContent) {
|
||||||
is MessageContent -> when (val type = eventContent.type) {
|
is MessageContent -> when (val type = eventContent.type) {
|
||||||
is ImageMessageType -> AttachmentThumbnailInfo(
|
is ImageMessageType -> AttachmentThumbnailInfo(
|
||||||
thumbnailSource = type.info?.thumbnailSource ?: type.source,
|
thumbnailSource = type.info?.thumbnailSource ?: type.source,
|
||||||
|
|
@ -680,12 +679,12 @@ private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready): Att
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun textForInReplyTo(inReplyTo: InReplyTo.Ready): String {
|
private fun textForInReplyTo(inReplyTo: InReplyToDetails): String {
|
||||||
return when (val eventContent = inReplyTo.content) {
|
return when (val eventContent = inReplyTo.eventContent) {
|
||||||
is MessageContent -> when (eventContent.type) {
|
is MessageContent -> when (eventContent.type) {
|
||||||
is LocationMessageType -> stringResource(CommonStrings.common_shared_location)
|
is LocationMessageType -> stringResource(CommonStrings.common_shared_location)
|
||||||
is VoiceMessageType -> stringResource(CommonStrings.common_voice_message)
|
is VoiceMessageType -> stringResource(CommonStrings.common_voice_message)
|
||||||
else -> eventContent.body
|
else -> inReplyTo.textContent ?: eventContent.body
|
||||||
}
|
}
|
||||||
is PollContent -> eventContent.question
|
is PollContent -> eventContent.question
|
||||||
else -> ""
|
else -> ""
|
||||||
|
|
@ -769,7 +768,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
|
||||||
body = "A long text which will be displayed on several lines and" +
|
body = "A long text which will be displayed on several lines and" +
|
||||||
" hopefully can be manually adjusted to test different behaviors."
|
" hopefully can be manually adjusted to test different behaviors."
|
||||||
),
|
),
|
||||||
inReplyTo = aInReplyToReady(replyContent),
|
inReplyTo = aInReplyToDetails(replyContent),
|
||||||
groupPosition = TimelineItemGroupPosition.First,
|
groupPosition = TimelineItemGroupPosition.First,
|
||||||
),
|
),
|
||||||
showReadReceipts = false,
|
showReadReceipts = false,
|
||||||
|
|
@ -794,7 +793,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
|
||||||
content = aTimelineItemImageContent().copy(
|
content = aTimelineItemImageContent().copy(
|
||||||
aspectRatio = 5f
|
aspectRatio = 5f
|
||||||
),
|
),
|
||||||
inReplyTo = aInReplyToReady(replyContent),
|
inReplyTo = aInReplyToDetails(replyContent),
|
||||||
isThreaded = true,
|
isThreaded = true,
|
||||||
groupPosition = TimelineItemGroupPosition.Last,
|
groupPosition = TimelineItemGroupPosition.Last,
|
||||||
),
|
),
|
||||||
|
|
@ -818,15 +817,16 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun aInReplyToReady(
|
private fun aInReplyToDetails(
|
||||||
replyContent: String,
|
replyContent: String,
|
||||||
): InReplyTo.Ready {
|
): InReplyToDetails {
|
||||||
return InReplyTo.Ready(
|
return InReplyToDetails(
|
||||||
eventId = EventId("\$event"),
|
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"),
|
senderId = UserId("@Sender:domain"),
|
||||||
senderDisplayName = "Sender",
|
senderDisplayName = "Sender",
|
||||||
senderAvatarUrl = null,
|
senderAvatarUrl = null,
|
||||||
|
textContent = replyContent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
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.FileExtensionExtractor
|
||||||
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
|
|
||||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
|
@ -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.UnknownMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
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.api.timeline.item.event.VoiceMessageType
|
||||||
|
import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
|
@ -85,6 +85,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
TimelineItemTextContent(
|
TimelineItemTextContent(
|
||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
|
plainText = messageType.body,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -161,11 +162,13 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
)
|
)
|
||||||
is TextMessageType -> TimelineItemTextContent(
|
is TextMessageType -> {
|
||||||
body = messageType.body,
|
TimelineItemTextContent(
|
||||||
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
body = messageType.body,
|
||||||
isEdited = content.isEdited,
|
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
||||||
)
|
isEdited = content.isEdited,
|
||||||
|
)
|
||||||
|
}
|
||||||
is OtherMessageType -> TimelineItemTextContent(
|
is OtherMessageType -> TimelineItemTextContent(
|
||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
htmlDocument = null,
|
htmlDocument = null,
|
||||||
|
|
|
||||||
|
|
@ -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.TimelineItemGroupPosition
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
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.TimelineItemReadReceipts
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.map
|
||||||
import io.element.android.libraries.core.bool.orTrue
|
import io.element.android.libraries.core.bool.orTrue
|
||||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
|
@ -92,7 +93,7 @@ class TimelineItemEventFactory @Inject constructor(
|
||||||
reactionsState = currentTimelineItem.computeReactionsState(),
|
reactionsState = currentTimelineItem.computeReactionsState(),
|
||||||
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
|
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
|
||||||
localSendState = currentTimelineItem.event.localSendState,
|
localSendState = currentTimelineItem.event.localSendState,
|
||||||
inReplyTo = currentTimelineItem.event.inReplyTo(),
|
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(),
|
||||||
isThreaded = currentTimelineItem.event.isThreaded(),
|
isThreaded = currentTimelineItem.event.isThreaded(),
|
||||||
debugInfo = currentTimelineItem.event.debugInfo,
|
debugInfo = currentTimelineItem.event.debugInfo,
|
||||||
origin = currentTimelineItem.event.origin,
|
origin = currentTimelineItem.event.origin,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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.TransactionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
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.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.LocalEventSendState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
@ -67,7 +66,7 @@ sealed interface TimelineItem {
|
||||||
val reactionsState: TimelineItemReactions,
|
val reactionsState: TimelineItemReactions,
|
||||||
val readReceiptState: TimelineItemReadReceipts,
|
val readReceiptState: TimelineItemReadReceipts,
|
||||||
val localSendState: LocalEventSendState?,
|
val localSendState: LocalEventSendState?,
|
||||||
val inReplyTo: InReplyTo?,
|
val inReplyTo: InReplyToDetails?,
|
||||||
val isThreaded: Boolean,
|
val isThreaded: Boolean,
|
||||||
val debugInfo: TimelineItemDebugInfo,
|
val debugInfo: TimelineItemDebugInfo,
|
||||||
val origin: TimelineItemEventOrigin?,
|
val origin: TimelineItemEventOrigin?,
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,13 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
data class TimelineItemEmoteContent(
|
data class TimelineItemEmoteContent(
|
||||||
override val body: String,
|
override val body: String,
|
||||||
override val htmlDocument: Document?,
|
override val htmlDocument: Document?,
|
||||||
|
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
) : TimelineItemTextBasedContent {
|
) : TimelineItemTextBasedContent {
|
||||||
override val type: String = "TimelineItemEmoteContent"
|
override val type: String = "TimelineItemEmoteContent"
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,13 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
data class TimelineItemNoticeContent(
|
data class TimelineItemNoticeContent(
|
||||||
override val body: String,
|
override val body: String,
|
||||||
override val htmlDocument: Document?,
|
override val htmlDocument: Document?,
|
||||||
|
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
) : TimelineItemTextBasedContent {
|
) : TimelineItemTextBasedContent {
|
||||||
override val type: String = "TimelineItemNoticeContent"
|
override val type: String = "TimelineItemNoticeContent"
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import org.jsoup.nodes.Document
|
||||||
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
|
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
|
||||||
val body: String
|
val body: String
|
||||||
val htmlDocument: Document?
|
val htmlDocument: Document?
|
||||||
|
val plainText: String
|
||||||
val isEdited: Boolean
|
val isEdited: Boolean
|
||||||
val htmlBody: String?
|
val htmlBody: String?
|
||||||
get() = htmlDocument?.body()?.html()
|
get() = htmlDocument?.body()?.html()
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,13 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
data class TimelineItemTextContent(
|
data class TimelineItemTextContent(
|
||||||
override val body: String,
|
override val body: String,
|
||||||
override val htmlDocument: Document?,
|
override val htmlDocument: Document?,
|
||||||
|
override val plainText: String = htmlDocument?.toPlainText() ?: body,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
) : TimelineItemTextBasedContent{
|
) : TimelineItemTextBasedContent{
|
||||||
override val type: String = "TimelineItemTextContent"
|
override val type: String = "TimelineItemTextContent"
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
|
||||||
) : MessageSummaryFormatter {
|
) : MessageSummaryFormatter {
|
||||||
override fun format(event: TimelineItem.Event): String {
|
override fun format(event: TimelineItem.Event): String {
|
||||||
return when (event.content) {
|
return when (event.content) {
|
||||||
is TimelineItemTextBasedContent -> event.content.body
|
is TimelineItemTextBasedContent -> event.content.plainText
|
||||||
is TimelineItemProfileChangeContent -> event.content.body
|
is TimelineItemProfileChangeContent -> event.content.body
|
||||||
is TimelineItemStateContent -> event.content.body
|
is TimelineItemStateContent -> event.content.body
|
||||||
is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location)
|
is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.messages.fixtures
|
package io.element.android.features.messages.fixtures
|
||||||
|
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
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.ReadReceiptData
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||||
|
|
@ -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.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
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.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.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
|
@ -39,7 +39,7 @@ internal fun aMessageEvent(
|
||||||
eventId: EventId? = AN_EVENT_ID,
|
eventId: EventId? = AN_EVENT_ID,
|
||||||
isMine: Boolean = true,
|
isMine: Boolean = true,
|
||||||
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
|
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
|
||||||
inReplyTo: InReplyTo? = null,
|
inReplyTo: InReplyToDetails? = null,
|
||||||
isThreaded: Boolean = false,
|
isThreaded: Boolean = false,
|
||||||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||||
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||||
|
|
|
||||||
|
|
@ -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!**")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,7 @@ dependencies {
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.services.toolbox.api)
|
implementation(projects.services.toolbox.api)
|
||||||
api(projects.libraries.eventformatter.api)
|
api(projects.libraries.eventformatter.api)
|
||||||
|
|
|
||||||
|
|
@ -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.UnknownMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
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.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.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -114,7 +115,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
|
||||||
return "* $senderDisplayName ${messageType.body}"
|
return "* $senderDisplayName ${messageType.body}"
|
||||||
}
|
}
|
||||||
is TextMessageType -> {
|
is TextMessageType -> {
|
||||||
messageType.body
|
messageType.toPlainText()
|
||||||
}
|
}
|
||||||
is VideoMessageType -> {
|
is VideoMessageType -> {
|
||||||
sp.getString(CommonStrings.common_video)
|
sp.getString(CommonStrings.common_video)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ data class NotificationData(
|
||||||
val content: NotificationContent,
|
val content: NotificationContent,
|
||||||
// For images for instance
|
// For images for instance
|
||||||
val contentUrl: String?,
|
val contentUrl: String?,
|
||||||
|
val hasMention: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface NotificationContent {
|
sealed interface NotificationContent {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ class NotificationMapper(
|
||||||
timestamp = item.timestamp() ?: clock.epochMillis(),
|
timestamp = item.timestamp() ?: clock.epochMillis(),
|
||||||
content = item.event.use { notificationContentMapper.map(it) },
|
content = item.event.use { notificationContentMapper.map(it) },
|
||||||
contentUrl = null,
|
contentUrl = null,
|
||||||
|
hasMention = item.hasMention.orFalse(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@ dependencies {
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.gif)
|
implementation(libs.coil.gif)
|
||||||
|
implementation(libs.jsoup)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
testImplementation(libs.test.robolectric)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,19 +14,46 @@
|
||||||
* limitations under the License.
|
* 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.FormattedBody
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.jsoup.nodes.Document
|
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? {
|
fun FormattedBody.toHtmlDocument(prefix: String? = null): Document? {
|
||||||
return takeIf { it.format == MessageFormat.HTML }?.body?.let { formattedBody ->
|
return takeIf { it.format == MessageFormat.HTML }?.body?.let { formattedBody ->
|
||||||
if (prefix != null) {
|
val dom = if (prefix != null) {
|
||||||
Jsoup.parse("$prefix $formattedBody")
|
Jsoup.parse("$prefix $formattedBody")
|
||||||
} else {
|
} else {
|
||||||
Jsoup.parse(formattedBody)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.UnknownMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
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.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.R
|
||||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||||
|
|
@ -81,6 +82,7 @@ class NotifiableEventResolver @Inject constructor(
|
||||||
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent? {
|
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent? {
|
||||||
return when (val content = this.content) {
|
return when (val content = this.content) {
|
||||||
is NotificationContent.MessageLike.RoomMessage -> {
|
is NotificationContent.MessageLike.RoomMessage -> {
|
||||||
|
val messageBody = descriptionFromMessageContent(content, senderDisplayName ?: content.senderId.value)
|
||||||
buildNotifiableMessageEvent(
|
buildNotifiableMessageEvent(
|
||||||
sessionId = userId,
|
sessionId = userId,
|
||||||
senderId = content.senderId,
|
senderId = content.senderId,
|
||||||
|
|
@ -89,7 +91,7 @@ class NotifiableEventResolver @Inject constructor(
|
||||||
noisy = isNoisy,
|
noisy = isNoisy,
|
||||||
timestamp = this.timestamp,
|
timestamp = this.timestamp,
|
||||||
senderName = senderDisplayName,
|
senderName = senderDisplayName,
|
||||||
body = descriptionFromMessageContent(content, senderDisplayName ?: content.senderId.value),
|
body = messageBody,
|
||||||
imageUriString = this.contentUrl,
|
imageUriString = this.contentUrl,
|
||||||
roomName = roomDisplayName,
|
roomName = roomDisplayName,
|
||||||
roomIsDirect = isDirect,
|
roomIsDirect = isDirect,
|
||||||
|
|
@ -216,7 +218,7 @@ class NotifiableEventResolver @Inject constructor(
|
||||||
is FileMessageType -> messageType.body
|
is FileMessageType -> messageType.body
|
||||||
is ImageMessageType -> messageType.body
|
is ImageMessageType -> messageType.body
|
||||||
is NoticeMessageType -> messageType.body
|
is NoticeMessageType -> messageType.body
|
||||||
is TextMessageType -> messageType.body
|
is TextMessageType -> messageType.toPlainText()
|
||||||
is VideoMessageType -> messageType.body
|
is VideoMessageType -> messageType.body
|
||||||
is LocationMessageType -> messageType.body
|
is LocationMessageType -> messageType.body
|
||||||
is OtherMessageType -> messageType.body
|
is OtherMessageType -> messageType.body
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue