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:
committed by
GitHub
parent
b1f2370e72
commit
f55d347387
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
|
||||
|
||||
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
|
||||
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(
|
||||
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(),
|
||||
|
||||
@@ -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
|
||||
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(
|
||||
}
|
||||
|
||||
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(
|
||||
fun CommonLayout(
|
||||
timestampPosition: TimestampPosition,
|
||||
showThreadDecoration: Boolean,
|
||||
inReplyToDetails: InReplyTo.Ready?,
|
||||
inReplyToDetails: InReplyToDetails?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val timestampLayoutModifier: Modifier
|
||||
@@ -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(
|
||||
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(
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@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 {
|
||||
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 {
|
||||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 5f
|
||||
),
|
||||
inReplyTo = aInReplyToReady(replyContent),
|
||||
inReplyTo = aInReplyToDetails(replyContent),
|
||||
isThreaded = true,
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
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(
|
||||
TimelineItemTextContent(
|
||||
body = messageType.body,
|
||||
htmlDocument = null,
|
||||
plainText = messageType.body,
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
} else {
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
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,
|
||||
|
||||
@@ -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.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 {
|
||||
val reactionsState: TimelineItemReactions,
|
||||
val readReceiptState: TimelineItemReadReceipts,
|
||||
val localSendState: LocalEventSendState?,
|
||||
val inReplyTo: InReplyTo?,
|
||||
val inReplyTo: InReplyToDetails?,
|
||||
val isThreaded: Boolean,
|
||||
val debugInfo: TimelineItemDebugInfo,
|
||||
val origin: TimelineItemEventOrigin?,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
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(
|
||||
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),
|
||||
|
||||
@@ -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.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.services.toolbox.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.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(
|
||||
return "* $senderDisplayName ${messageType.body}"
|
||||
}
|
||||
is TextMessageType -> {
|
||||
messageType.body
|
||||
messageType.toPlainText()
|
||||
}
|
||||
is VideoMessageType -> {
|
||||
sp.getString(CommonStrings.common_video)
|
||||
|
||||
@@ -36,6 +36,7 @@ data class NotificationData(
|
||||
val content: NotificationContent,
|
||||
// For images for instance
|
||||
val contentUrl: String?,
|
||||
val hasMention: Boolean,
|
||||
)
|
||||
|
||||
sealed interface NotificationContent {
|
||||
|
||||
@@ -53,6 +53,7 @@ class NotificationMapper(
|
||||
timestamp = item.timestamp() ?: clock.epochMillis(),
|
||||
content = item.event.use { notificationContentMapper.map(it) },
|
||||
contentUrl = null,
|
||||
hasMention = item.hasMention.orFalse(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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("@")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.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(
|
||||
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(
|
||||
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(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user