1
0
mirror of https://github.com/ouchadam/small-talk.git synced 2025-03-26 00:40:25 +01:00

adding custom html parsing with tag styling

This commit is contained in:
Adam Brown 2022-10-23 23:15:46 +01:00
parent e13ce95b83
commit fddcdaa50c
27 changed files with 347 additions and 128 deletions
chat-engine/src/main/kotlin/app/dapk/st/engine
core/src/main/kotlin/app/dapk/st/core
design-library/src/main/kotlin/app/dapk/st/design/components
domains/store/src/main/kotlin/app/dapk/st/domain/sync
features
messenger/src/main/kotlin/app/dapk/st/messenger
notifications/src/main/kotlin/app/dapk/st/notifications
matrix-chat-engine/src/main/kotlin/app/dapk/st/engine
matrix
common/src/main/kotlin/app/dapk/st/matrix/common
services

@ -125,7 +125,7 @@ sealed class RoomEvent {
data class Message(
override val eventId: EventId,
override val utcTimestamp: Long,
val content: String,
val content: RichText,
override val author: RoomMember,
override val meta: MessageMeta,
override val edited: Boolean = false,

@ -0,0 +1,13 @@
package app.dapk.st.core
data class RichText(val parts: Set<Part>) {
sealed interface Part {
data class Normal(val content: String) : Part
data class Link(val url: String, val label: String) : Part
data class Bold(val content: String) : Part
data class Italic(val content: String) : Part
data class BoldItalic(val content: String) : Part
}
}
fun RichText.asString() = parts.joinToString(separator = "")

@ -7,6 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -15,18 +16,29 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.dapk.st.core.RichText
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
private val ENCRYPTED_MESSAGE = RichText(setOf(RichText.Part.Normal("Encrypted message")))
sealed interface BubbleModel {
val event: Event
data class Text(val content: String, override val event: Event) : BubbleModel
data class Text(val content: RichText, override val event: Event) : BubbleModel
data class Encrypted(override val event: Event) : BubbleModel
data class Image(val imageContent: ImageContent, val imageRequest: ImageRequest, override val event: Event) : BubbleModel {
data class ImageContent(val width: Int?, val height: Int?, val url: String)
@ -66,7 +78,7 @@ private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Com
@Composable
private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, status: @Composable () -> Unit, onLongClick: () -> Unit) {
TextBubble(bubble, BubbleModel.Text(content = "Encrypted message", model.event), status, onLongClick)
TextBubble(bubble, BubbleModel.Text(content = ENCRYPTED_MESSAGE, model.event), status, onLongClick)
}
@Composable
@ -111,7 +123,7 @@ private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @C
when (val replyingTo = model.replyingTo) {
is BubbleModel.Text -> {
Text(
text = replyingTo.content,
text = replyingTo.content.toAnnotatedText(),
color = bubble.textColor().copy(alpha = 0.8f),
fontSize = 14.sp,
modifier = Modifier.wrapContentSize(),
@ -153,7 +165,7 @@ private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @C
when (val message = model.reply) {
is BubbleModel.Text -> TextContent(bubble, message.content)
is BubbleModel.Encrypted -> TextContent(bubble, "Encrypted message")
is BubbleModel.Encrypted -> TextContent(bubble, ENCRYPTED_MESSAGE)
is BubbleModel.Image -> {
Spacer(modifier = Modifier.height(4.dp))
Image(
@ -233,16 +245,43 @@ private fun Footer(event: BubbleModel.Event, bubble: BubbleMeta, status: @Compos
}
@Composable
private fun TextContent(bubble: BubbleMeta, text: String) {
Text(
text = text,
color = bubble.textColor(),
fontSize = 15.sp,
private fun TextContent(bubble: BubbleMeta, text: RichText) {
val annotatedText = text.toAnnotatedText()
val uriHandler = LocalUriHandler.current
ClickableText(
text = annotatedText,
style = TextStyle(color = bubble.textColor(), fontSize = 15.sp, textAlign = TextAlign.Start),
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
onClick = {
annotatedText.getStringAnnotations("url", it, it).firstOrNull()?.let {
uriHandler.openUri(it.item)
}
}
)
}
val hyperLinkStyle = SpanStyle(
color = Color(0xff64B5F6),
textDecoration = TextDecoration.Underline
)
fun RichText.toAnnotatedText() = buildAnnotatedString {
parts.forEach {
when (it) {
is RichText.Part.Bold -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(it.content) }
is RichText.Part.BoldItalic -> append(it.content)
is RichText.Part.Italic -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(it.content) }
is RichText.Part.Link -> {
pushStringAnnotation("url", annotation = it.url)
withStyle(hyperLinkStyle) { append(it.label) }
pop()
}
is RichText.Part.Normal -> append(it.content)
}
}
}
@Composable
private fun AuthorName(event: BubbleModel.Event, bubble: BubbleMeta) {
Text(

@ -13,7 +13,10 @@ import app.dapk.st.matrix.sync.RoomStore
import com.squareup.sqldelight.runtime.coroutines.asFlow
import com.squareup.sqldelight.runtime.coroutines.mapToList
import com.squareup.sqldelight.runtime.coroutines.mapToOneNotNull
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
private val json = Json

@ -8,7 +8,6 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
@ -43,6 +42,7 @@ import app.dapk.st.engine.MessageMeta
import app.dapk.st.engine.MessengerState
import app.dapk.st.engine.RoomEvent
import app.dapk.st.engine.RoomState
import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.UserId
import app.dapk.st.messenger.gallery.ImageGalleryActivityPayload
@ -210,7 +210,7 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio
@Composable
private fun RoomEvent.toModel(event: BubbleModel.Event): BubbleModel = when (this) {
is RoomEvent.Message -> BubbleModel.Text(this.content, event)
is RoomEvent.Message -> BubbleModel.Text(this.content.toApp(), event)
is RoomEvent.Encrypted -> BubbleModel.Encrypted(event)
is RoomEvent.Image -> {
val imageRequest = LocalImageRequestFactory.current
@ -226,6 +226,18 @@ private fun RoomEvent.toModel(event: BubbleModel.Event): BubbleModel = when (thi
}
}
private fun RichText.toApp(): app.dapk.st.core.RichText {
return app.dapk.st.core.RichText(this.parts.map {
when (it) {
is RichText.Part.Bold -> app.dapk.st.core.RichText.Part.Bold(it.content)
is RichText.Part.BoldItalic -> app.dapk.st.core.RichText.Part.BoldItalic(it.content)
is RichText.Part.Italic -> app.dapk.st.core.RichText.Part.Italic(it.content)
is RichText.Part.Link -> app.dapk.st.core.RichText.Part.Link(it.url, it.label)
is RichText.Part.Normal -> app.dapk.st.core.RichText.Part.Normal(it.content)
}
}.toSet())
}
@Composable
private fun SendStatus(message: RoomEvent) {
when (val meta = message.meta) {
@ -319,7 +331,7 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un
)
Text(
text = it.content,
text = it.content.toApp().toAnnotatedText(),
color = SmallTalkTheme.extendedColors.onOthersBubble,
fontSize = 14.sp,
maxLines = 2,

@ -4,6 +4,7 @@ import android.os.Build
import androidx.lifecycle.viewModelScope
import app.dapk.st.core.DeviceMeta
import app.dapk.st.core.Lce
import app.dapk.st.core.asString
import app.dapk.st.core.extensions.takeIfContent
import app.dapk.st.design.components.BubbleModel
import app.dapk.st.domain.application.message.MessageOptionsStore
@ -11,6 +12,7 @@ import app.dapk.st.engine.ChatEngine
import app.dapk.st.engine.RoomEvent
import app.dapk.st.engine.SendMessage
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.asString
import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.viewmodel.DapkViewModel
import app.dapk.st.viewmodel.MutableStateFactory
@ -116,7 +118,7 @@ internal class MessengerViewModel(
originalMessage = when (it) {
is RoomEvent.Image -> TODO()
is RoomEvent.Reply -> TODO()
is RoomEvent.Message -> it.content
is RoomEvent.Message -> it.content.asString()
is RoomEvent.Encrypted -> error("Should never happen")
},
eventId = it.eventId,
@ -161,7 +163,7 @@ private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) {
is BubbleModel.Encrypted -> CopyableResult.NothingToCopy
is BubbleModel.Image -> CopyableResult.NothingToCopy
is BubbleModel.Reply -> this.reply.findCopyableContent()
is BubbleModel.Text -> CopyableResult.Content(CopyToClipboard.Copyable.Text(this.content))
is BubbleModel.Text -> CopyableResult.Content(CopyToClipboard.Copyable.Text(this.content.asString()))
}
private sealed interface CopyableResult {

@ -2,6 +2,7 @@ package app.dapk.st.notifications
import app.dapk.st.engine.RoomEvent
import app.dapk.st.matrix.common.RoomMember
import app.dapk.st.matrix.common.asString
class RoomEventsToNotifiableMapper {
@ -11,7 +12,7 @@ class RoomEventsToNotifiableMapper {
private fun RoomEvent.toNotifiableContent(): String = when (this) {
is RoomEvent.Image -> "\uD83D\uDCF7"
is RoomEvent.Message -> this.content
is RoomEvent.Message -> this.content.asString()
is RoomEvent.Reply -> this.message.toNotifiableContent()
is RoomEvent.Encrypted -> "Encrypted message"
}

@ -68,7 +68,7 @@ internal class DirectoryUseCase(
this.copy(
lastMessage = RoomOverview.LastMessage(
content = when (val message = latestEcho.message) {
is MessageService.Message.TextMessage -> message.content.body
is MessageService.Message.TextMessage -> message.content.body.parts.joinToString("")
is MessageService.Message.ImageMessage -> "\uD83D\uDCF7"
},
utcTimestamp = latestEcho.timestampUtc,

@ -1,5 +1,6 @@
package app.dapk.st.engine
import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.message.MessageService
import app.dapk.st.matrix.message.internal.ImageContentReader
import java.time.Clock
@ -41,7 +42,7 @@ internal class SendMessageUseCase(
}
private fun createTextMessage(message: SendMessage.TextMessage, room: RoomOverview) = MessageService.Message.TextMessage(
content = MessageService.Message.Content.TextContent(message.content),
content = MessageService.Message.Content.TextContent(RichText.of(message.content)),
roomId = room.roomId,
sendEncrypted = room.isEncrypted,
localId = localIdFactory.create(),
@ -49,7 +50,7 @@ internal class SendMessageUseCase(
reply = message.reply?.let {
MessageService.Message.TextMessage.Reply(
author = it.author,
originalMessage = it.originalMessage,
originalMessage = RichText.of(it.originalMessage),
replyContent = message.content,
eventId = it.eventId,
timestampUtc = it.timestampUtc,

@ -9,11 +9,31 @@ data class RichText(@SerialName("parts") val parts: Set<Part>) {
sealed interface Part {
@Serializable
data class Normal(@SerialName("content") val content: String) : Part
@Serializable
data class Link(@SerialName("url") val url: String, @SerialName("label") val label: String) : Part
@Serializable
data class Bold(@SerialName("content") val content: String) : Part
@Serializable
data class Italic(@SerialName("content") val content: String) : Part
@Serializable
data class BoldItalic(@SerialName("content") val content: String) : Part
}
companion object {
fun of(text: String) = RichText(setOf(RichText.Part.Normal(text)))
}
}
fun RichText.asString() = parts.joinToString(separator = "")
fun RichText.asString() = parts.joinToString(separator = "") {
when(it) {
is RichText.Part.Bold -> it.content
is RichText.Part.BoldItalic -> it.content
is RichText.Part.Italic -> it.content
is RichText.Part.Link -> it.label
is RichText.Part.Normal -> it.content
}
}

@ -44,7 +44,7 @@ interface MessageService : MatrixService {
@Serializable
data class Reply(
val author: RoomMember,
val originalMessage: String,
val originalMessage: RichText,
val replyContent: String,
val eventId: EventId,
val timestampUtc: Long,
@ -65,7 +65,7 @@ interface MessageService : MatrixService {
sealed class Content {
@Serializable
data class TextContent(
@SerialName("body") val body: String,
@SerialName("body") val body: RichText,
@SerialName("msgtype") val type: String = MessageType.TEXT.value,
) : Content()

@ -153,13 +153,13 @@ class ApiMessageMapper {
fun Message.TextMessage.toContents(reply: Message.TextMessage.Reply?) = when (reply) {
null -> ApiMessage.TextMessage.TextContent(
body = this.content.body,
body = this.content.body.parts.joinToString(""),
)
else -> ApiMessage.TextMessage.TextContent(
body = buildReplyFallback(reply.originalMessage, reply.author.id, reply.replyContent),
body = buildReplyFallback(reply.originalMessage.parts.joinToString(""), reply.author.id, reply.replyContent),
relatesTo = ApiMessage.RelatesTo(ApiMessage.RelatesTo.InReplyTo(reply.eventId)),
formattedBody = buildFormattedReply(reply.author.id, reply.originalMessage, reply.replyContent, this.roomId, reply.eventId),
formattedBody = buildFormattedReply(reply.author.id, reply.originalMessage.parts.joinToString(""), reply.replyContent, this.roomId, reply.eventId),
format = "org.matrix.custom.html"
)
}

@ -45,7 +45,7 @@ sealed class RoomEvent {
data class Message(
@SerialName("event_id") override val eventId: EventId,
@SerialName("timestamp") override val utcTimestamp: Long,
@SerialName("content") val content: String,
@SerialName("content") val content: RichText,
@SerialName("author") override val author: RoomMember,
@SerialName("meta") override val meta: MessageMeta,
@SerialName("edited") val edited: Boolean = false,

@ -8,6 +8,7 @@ import app.dapk.st.matrix.sync.internal.DefaultSyncService
import app.dapk.st.matrix.sync.internal.request.*
import app.dapk.st.matrix.sync.internal.room.MessageDecrypter
import app.dapk.st.matrix.sync.internal.room.MissingMessageDecrypter
import app.dapk.st.matrix.sync.internal.sync.RichMessageParser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
@ -53,6 +54,7 @@ fun MatrixServiceInstaller.installSyncService(
roomMembersService: ServiceDepFactory<RoomMembersService>,
errorTracker: ErrorTracker,
coroutineDispatchers: CoroutineDispatchers,
syncConfig: SyncConfig = SyncConfig(),
): InstallExtender<SyncService> {
this.serializers {
@ -96,6 +98,7 @@ fun MatrixServiceInstaller.installSyncService(
errorTracker = errorTracker,
coroutineDispatchers = coroutineDispatchers,
syncConfig = syncConfig,
richMessageParser = RichMessageParser()
)
}
}

@ -41,13 +41,14 @@ internal class DefaultSyncService(
errorTracker: ErrorTracker,
private val coroutineDispatchers: CoroutineDispatchers,
syncConfig: SyncConfig,
richMessageParser: RichMessageParser,
) : SyncService {
private val syncEventsFlow = MutableStateFlow<List<SyncService.SyncEvent>>(emptyList())
private val roomDataSource by lazy { RoomDataSource(roomStore, logger) }
private val eventDecrypter by lazy { SyncEventDecrypter(messageDecrypter, json, logger) }
private val roomEventsDecrypter by lazy { RoomEventsDecrypter(messageDecrypter, json, logger) }
private val roomEventsDecrypter by lazy { RoomEventsDecrypter(messageDecrypter, richMessageParser, json, logger) }
private val roomRefresher by lazy { RoomRefresher(roomDataSource, roomEventsDecrypter, logger) }
private val sync2 by lazy {
@ -57,7 +58,7 @@ internal class DefaultSyncService(
roomMembersService,
roomDataSource,
TimelineEventsProcessor(
RoomEventCreator(roomMembersService, errorTracker, RoomEventFactory(roomMembersService)),
RoomEventCreator(roomMembersService, errorTracker, RoomEventFactory(roomMembersService, richMessageParser)),
roomEventsDecrypter,
eventDecrypter,
EventLookupUseCase(roomStore)

@ -6,10 +6,12 @@ import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent.TimelineMessage.Content.Image
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent.TimelineMessage.Content.Text
import app.dapk.st.matrix.sync.internal.request.DecryptedContent
import app.dapk.st.matrix.sync.internal.sync.RichMessageParser
import kotlinx.serialization.json.Json
internal class RoomEventsDecrypter(
private val messageDecrypter: MessageDecrypter,
private val richMessageParser: RichMessageParser,
private val json: Json,
private val logger: MatrixLogger,
) {
@ -50,7 +52,7 @@ internal class RoomEventsDecrypter(
meta = this.meta,
edited = this.edited,
redacted = this.redacted,
content = content.body ?: ""
content = richMessageParser.parse(content.body ?: "")
)
private fun RoomEvent.Encrypted.createImageEvent(content: Image, userCredentials: UserCredentials) = RoomEvent.Image(

@ -1,57 +1,116 @@
package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.RichText
data class Tag(val name: String, val inner: String, val content: String)
import app.dapk.st.matrix.common.RichText.Part.*
class RichMessageParser {
fun parse(input: String): RichText {
val buffer = mutableSetOf<RichText.Part>()
var openIndex = 0
var closeIndex = 0
while (openIndex != -1) {
val foundIndex = input.indexOf('<', startIndex = openIndex)
if (foundIndex != -1) {
closeIndex = input.indexOf('>', startIndex = openIndex)
return kotlin.runCatching {
val buffer = mutableSetOf<RichText.Part>()
var openIndex = 0
var closeIndex = 0
var lastStartIndex = 0
while (openIndex != -1) {
val foundIndex = input.indexOf('<', startIndex = openIndex)
if (foundIndex != -1) {
closeIndex = input.indexOf('>', startIndex = foundIndex)
if (closeIndex == -1) {
openIndex++
} else {
val wholeTag = input.substring(foundIndex, closeIndex + 1)
val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' })
if (closeIndex == -1) {
openIndex++
} else {
val wholeTag = input.substring(foundIndex, closeIndex + 1)
val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' })
val exitTag = "<$tagName/>"
val exitIndex = input.indexOf(exitTag, startIndex = closeIndex + 1)
val tagContent = input.substring(closeIndex + 1, exitIndex)
val tag = Tag(name = tagName, wholeTag, tagContent)
println("found $tag")
if (openIndex != foundIndex) {
buffer.add(RichText.Part.Normal(input.substring(openIndex, foundIndex)))
}
openIndex = exitIndex + exitTag.length
when (tagName) {
"a" -> {
val findHrefUrl = wholeTag.substringAfter("href=").replace("\"", "").removeSuffix(">")
buffer.add(RichText.Part.Link(url = findHrefUrl, label = tag.content))
if (tagName == "br") {
if (openIndex != foundIndex) {
buffer.add(Normal(input.substring(openIndex, foundIndex)))
}
buffer.add(Normal("\n"))
openIndex = foundIndex + "<br />".length
lastStartIndex = openIndex
continue
}
"b" -> buffer.add(RichText.Part.Bold(tagContent))
"i" -> buffer.add(RichText.Part.Italic(tagContent))
}
}
} else {
// exit
if (openIndex < input.length) {
buffer.add(RichText.Part.Normal(input.substring(openIndex)))
}
break
}
}
val exitTag = "</$tagName>"
val exitIndex = input.indexOf(exitTag, startIndex = closeIndex)
return RichText(buffer)
println("$exitTag : $exitIndex")
if (exitIndex == -1) {
openIndex++
} else {
if (openIndex != foundIndex) {
buffer.add(Normal(input.substring(openIndex, foundIndex)))
}
val tagContent = input.substring(closeIndex + 1, exitIndex)
openIndex = exitIndex + exitTag.length
lastStartIndex = openIndex
when (tagName) {
"a" -> {
val findHrefUrl = wholeTag.substringAfter("href=").replace("\"", "").removeSuffix(">")
buffer.add(Link(url = findHrefUrl, label = tagContent))
}
"b" -> buffer.add(Bold(tagContent))
"strong" -> buffer.add(Bold(tagContent))
"i" -> buffer.add(Italic(tagContent))
"em" -> buffer.add(Italic(tagContent))
else -> buffer.add(Normal(tagContent))
}
}
}
} else {
// check for urls
val urlIndex = input.indexOf("http", startIndex = openIndex)
if (urlIndex != -1) {
if (lastStartIndex != urlIndex) {
buffer.add(Normal(input.substring(lastStartIndex, urlIndex)))
}
val substring1 = input.substring(urlIndex)
val urlEndIndex = substring1.indexOfFirst { it == '\n' || it == ' ' }
when {
urlEndIndex == -1 -> {
val last = substring1.last()
val url = substring1.removeSuffix(".").removeSuffix(",")
buffer.add(Link(url = url, label = url))
if (last == '.' || last == ',') {
buffer.add(Normal(last.toString()))
}
break
}
else -> {
val substring = input.substring(urlIndex, urlEndIndex)
val last = substring.last()
if (last == '.' || last == ',') {
substring.dropLast(1)
}
val url = substring.removeSuffix(".").removeSuffix(",")
buffer.add(Link(url = url, label = url))
openIndex = if (substring.endsWith('.') || substring.endsWith(',')) urlEndIndex - 1 else urlEndIndex
lastStartIndex = openIndex
continue
}
}
}
// exit
if (lastStartIndex < input.length) {
buffer.add(Normal(input.substring(lastStartIndex)))
}
break
}
}
RichText(buffer)
}.onFailure {
it.printStackTrace()
println(input)
}.getOrThrow()
}
}

@ -51,7 +51,7 @@ class RoomDataSource(
}
}
private fun RoomEvent.redact() = RoomEvent.Message(this.eventId, this.utcTimestamp, "Redacted", this.author, this.meta, redacted = true)
private fun RoomEvent.redact() = RoomEvent.Message(this.eventId, this.utcTimestamp, RichText.of("Redacted"), this.author, this.meta, redacted = true)
private fun RoomState.replaceEvent(old: RoomEvent, new: RoomEvent): RoomState {
val updatedEvents = this.events.toMutableList().apply {

@ -147,8 +147,8 @@ internal class TimelineEventMapper(
}
}
// TODO handle edits
private fun RoomEvent.Message.edited(edit: ApiTimelineEvent.TimelineMessage) = this.copy(
content = edit.asTextContent().body?.removePrefix(" * ")?.trim() ?: "redacted",
utcTimestamp = edit.utcTimestamp,
edited = true,
)
@ -156,7 +156,7 @@ internal class TimelineEventMapper(
private suspend fun RoomEventFactory.mapToRoomEvent(source: ApiTimelineEvent.TimelineMessage): RoomEvent {
return when (source.content) {
is ApiTimelineEvent.TimelineMessage.Content.Image -> source.toImageMessage(userCredentials, roomId)
is ApiTimelineEvent.TimelineMessage.Content.Text -> source.toTextMessage(roomId)
is ApiTimelineEvent.TimelineMessage.Content.Text -> source.toTextMessage(roomId, content = source.asTextContent().formattedBody ?: source.content.body ?: "")
ApiTimelineEvent.TimelineMessage.Content.Ignored -> throw IllegalStateException()
}
}

@ -10,17 +10,18 @@ import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
private val UNKNOWN_AUTHOR = RoomMember(id = UserId("unknown"), displayName = null, avatarUrl = null)
internal class RoomEventFactory(
private val roomMembersService: RoomMembersService
private val roomMembersService: RoomMembersService,
private val richMessageParser: RichMessageParser,
) {
suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage(
roomId: RoomId,
content: String = this.asTextContent().formattedBody?.stripTags() ?: this.asTextContent().body ?: "redacted",
content: String,
edited: Boolean = false,
utcTimestamp: Long = this.utcTimestamp,
) = RoomEvent.Message(
eventId = this.id,
content = content,
content = richMessageParser.parse(content),
author = roomMembersService.find(roomId, this.senderId) ?: UNKNOWN_AUTHOR,
utcTimestamp = utcTimestamp,
meta = MessageMeta.FromServer,

@ -1,9 +1,6 @@
package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.AvatarUrl
import app.dapk.st.matrix.common.RoomMember
import app.dapk.st.matrix.common.UserCredentials
import app.dapk.st.matrix.common.convertMxUrToUrl
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.sync.*
import app.dapk.st.matrix.sync.internal.request.ApiSyncRoom
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
@ -79,7 +76,7 @@ internal fun List<RoomEvent>.findLastMessage(): LastMessage? {
private fun RoomEvent.toTextContent(): String = when (this) {
is RoomEvent.Image -> "\uD83D\uDCF7"
is RoomEvent.Message -> this.content
is RoomEvent.Message -> this.content.asString()
is RoomEvent.Reply -> this.message.toTextContent()
is RoomEvent.Encrypted -> "Encrypted message"
}

@ -2,8 +2,10 @@ package app.dapk.st.matrix.sync.internal.room
import app.dapk.st.matrix.common.EncryptedMessageContent
import app.dapk.st.matrix.common.JsonString
import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.internal.request.DecryptedContent
import app.dapk.st.matrix.sync.internal.sync.RichMessageParser
import fake.FakeMatrixLogger
import fake.FakeMessageDecrypter
import fixture.*
@ -31,6 +33,7 @@ class RoomEventsDecrypterTest {
private val roomEventsDecrypter = RoomEventsDecrypter(
fakeMessageDecrypter,
RichMessageParser(),
Json,
FakeMatrixLogger(),
)
@ -88,7 +91,7 @@ private fun RoomEvent.Encrypted.MegOlmV1.toModel() = EncryptedMessageContent.Meg
private fun RoomEvent.Encrypted.toText(text: String) = RoomEvent.Message(
this.eventId,
this.utcTimestamp,
content = text,
content = RichText.of(text),
this.author,
this.meta,
this.edited,

@ -1,5 +1,6 @@
package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.RichText
import fake.FakeRoomStore
import fixture.aMatrixRoomMessageEvent
import fixture.anEventId
@ -11,8 +12,8 @@ import org.junit.Test
private val AN_EVENT_ID = anEventId()
private val A_TIMELINE_EVENT = anApiTimelineTextEvent(AN_EVENT_ID, content = aTimelineTextEventContent(body = "timeline event"))
private val A_ROOM_EVENT = aMatrixRoomMessageEvent(AN_EVENT_ID, content = "previous room event")
private val A_PERSISTED_EVENT = aMatrixRoomMessageEvent(AN_EVENT_ID, content = "persisted event")
private val A_ROOM_EVENT = aMatrixRoomMessageEvent(AN_EVENT_ID, content = RichText.of("previous room event"))
private val A_PERSISTED_EVENT = aMatrixRoomMessageEvent(AN_EVENT_ID, content = RichText.of("persisted event"))
class EventLookupUseCaseTest {

@ -1,6 +1,8 @@
package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.common.RichText.Part.Link
import app.dapk.st.matrix.common.RichText.Part.Normal
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Ignore
import org.junit.Test
@ -12,32 +14,88 @@ class RichMessageParserTest {
@Test
fun `parses plain text`() = runParserTest(
input = "Hello world!",
expected = RichText(setOf(RichText.Part.Normal("Hello world!")))
expected = RichText(setOf(Normal("Hello world!")))
)
@Test
fun `parses nested b tags`() = runParserTest(
fun `skips p tags`() = runParserTest(
input = "Hello world! <p>foo bar</p> after paragraph",
expected = RichText(setOf(Normal("Hello world! "), Normal("foo bar"), Normal(" after paragraph")))
)
@Test
fun `skips header tags`() = runParserTest(
Case(
input = """hello <b>wor<b/>ld""",
input = "<h1>hello</h1>",
expected = RichText(setOf(Normal("hello")))
),
Case(
input = "<h2>hello</h2>",
expected = RichText(setOf(Normal("hello")))
),
Case(
input = "<h3>hello</h3>",
expected = RichText(setOf(Normal("hello")))
),
)
@Test
fun `replaces br tags`() = runParserTest(
input = "Hello world!<br />next line<br />another line",
expected = RichText(setOf(Normal("Hello world!"), Normal("\n"), Normal("next line"), Normal("\n"), Normal("another line")))
)
@Test
fun `parses urls`() = runParserTest(
Case(
input = "https://google.com",
expected = RichText(setOf(Link("https://google.com", "https://google.com")))
),
Case(
input = "https://google.com. after link",
expected = RichText(setOf(Link("https://google.com", "https://google.com"), Normal(". after link")))
),
Case(
input = "ending sentence with url https://google.com.",
expected = RichText(setOf(Normal("ending sentence with url "), Link("https://google.com", "https://google.com"), Normal(".")))
),
)
@Test
fun `parses styling text`() = runParserTest(
input = "<em>hello</em> <strong>world</strong>",
expected = RichText(setOf(RichText.Part.Italic("hello"), Normal(" "), RichText.Part.Bold("world")))
)
@Test
fun `parses raw reply text`() = runParserTest(
input = "> <@a-matrix-id:domain.foo> This is a reply",
expected = RichText(setOf(Normal("> <@a-matrix-id:domain.foo> This is a reply")))
)
@Test
fun `parses strong tags`() = runParserTest(
Case(
input = """hello <strong>wor</strong>ld""",
expected = RichText(
setOf(
RichText.Part.Normal("hello "),
Normal("hello "),
RichText.Part.Bold("wor"),
RichText.Part.Normal("ld"),
Normal("ld"),
)
)
),
)
@Test
fun `parses nested i tags`() = runParserTest(
fun `parses em tags`() = runParserTest(
Case(
input = """hello <i>wor<i/>ld""",
input = """hello <em>wor</em>ld""",
expected = RichText(
setOf(
RichText.Part.Normal("hello "),
Normal("hello "),
RichText.Part.Italic("wor"),
RichText.Part.Normal("ld"),
Normal("ld"),
)
)
),
@ -50,9 +108,9 @@ class RichMessageParserTest {
input = """hello <b><i>wor<i/><b/>ld""",
expected = RichText(
setOf(
RichText.Part.Normal("hello "),
Normal("hello "),
RichText.Part.BoldItalic("wor"),
RichText.Part.Normal("ld"),
Normal("ld"),
)
)
),
@ -60,8 +118,8 @@ class RichMessageParserTest {
input = """<a href="www.google.com"><a href="www.google.com">www.google.com<a/><a/>""",
expected = RichText(
setOf(
RichText.Part.Link(url = "www.google.com", label = "www.google.com"),
RichText.Part.Link(url = "www.bing.com", label = "www.bing.com"),
Link(url = "www.google.com", label = "www.google.com"),
Link(url = "www.bing.com", label = "www.bing.com"),
)
)
)
@ -70,21 +128,21 @@ class RichMessageParserTest {
@Test
fun `parses 'a' tags`() = runParserTest(
Case(
input = """hello world <a href="www.google.com">a link!<a/> more content.""",
input = """hello world <a href="www.google.com">a link!</a> more content.""",
expected = RichText(
setOf(
RichText.Part.Normal("hello world "),
RichText.Part.Link(url = "www.google.com", label = "a link!"),
RichText.Part.Normal(" more content."),
Normal("hello world "),
Link(url = "www.google.com", label = "a link!"),
Normal(" more content."),
)
)
),
Case(
input = """<a href="www.google.com">www.google.com<a/><a href="www.bing.com">www.bing.com<a/>""",
input = """<a href="www.google.com">www.google.com</a><a href="www.bing.com">www.bing.com</a>""",
expected = RichText(
setOf(
RichText.Part.Link(url = "www.google.com", label = "www.google.com"),
RichText.Part.Link(url = "www.bing.com", label = "www.bing.com"),
Link(url = "www.google.com", label = "www.google.com"),
Link(url = "www.bing.com", label = "www.bing.com"),
)
)
),

@ -1,6 +1,8 @@
package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.common.asString
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
@ -15,11 +17,11 @@ import org.junit.Test
private val A_ROOM_ID = aRoomId()
private val A_SENDER = aRoomMember()
private val EMPTY_LOOKUP = FakeLookup(LookupResult(apiTimelineEvent = null, roomEvent = null))
private const val A_TEXT_EVENT_MESSAGE = "a text message"
private const val A_REPLY_EVENT_MESSAGE = "a reply to another message"
private val A_TEXT_EVENT_MESSAGE = RichText.of("a text message")
private val A_REPLY_EVENT_MESSAGE = RichText.of("a reply to another message")
private val A_TEXT_EVENT = anApiTimelineTextEvent(
senderId = A_SENDER.id,
content = aTimelineTextEventContent(body = A_TEXT_EVENT_MESSAGE)
content = aTimelineTextEventContent(body = A_TEXT_EVENT_MESSAGE.asString())
)
private val A_TEXT_EVENT_WITHOUT_CONTENT = anApiTimelineTextEvent(
senderId = A_SENDER.id,
@ -31,7 +33,7 @@ internal class RoomEventCreatorTest {
private val fakeRoomMembersService = FakeRoomMembersService()
private val roomEventCreator = RoomEventCreator(fakeRoomMembersService, FakeErrorTracker(), RoomEventFactory(fakeRoomMembersService))
private val roomEventCreator = RoomEventCreator(fakeRoomMembersService, FakeErrorTracker(), RoomEventFactory(fakeRoomMembersService, RichMessageParser()))
@Test
fun `given Megolm encrypted event then maps to encrypted room message`() = runTest {
@ -89,7 +91,7 @@ internal class RoomEventCreatorTest {
result shouldBeEqualTo aMatrixRoomMessageEvent(
eventId = A_TEXT_EVENT_WITHOUT_CONTENT.id,
utcTimestamp = A_TEXT_EVENT_WITHOUT_CONTENT.utcTimestamp,
content = "redacted",
content = RichText.of("redacted"),
author = A_SENDER,
)
}
@ -97,14 +99,14 @@ internal class RoomEventCreatorTest {
@Test
fun `given edited event with no relation then maps to new room message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val editEvent = anApiTimelineTextEvent().toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE)
val editEvent = anApiTimelineTextEvent().toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE.asString())
val result = with(roomEventCreator) { editEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) }
result shouldBeEqualTo aMatrixRoomMessageEvent(
eventId = editEvent.id,
utcTimestamp = editEvent.utcTimestamp,
content = editEvent.asTextContent().body!!,
content = RichText.of(editEvent.asTextContent().body!!),
author = A_SENDER,
edited = true
)
@ -114,7 +116,7 @@ internal class RoomEventCreatorTest {
fun `given edited event which relates to a timeline event then updates existing message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = anApiTimelineTextEvent(utcTimestamp = 0)
val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE)
val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -132,7 +134,7 @@ internal class RoomEventCreatorTest {
fun `given edited event which relates to a room event then updates existing message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = aMatrixRoomMessageEvent()
val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE)
val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -150,7 +152,7 @@ internal class RoomEventCreatorTest {
fun `given edited event which relates to a room reply event then only updates message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = aRoomReplyMessageEvent(message = aMatrixRoomMessageEvent())
val editedMessage = (originalMessage.message as RoomEvent.Message).toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE)
val editedMessage = (originalMessage.message as RoomEvent.Message).toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -170,7 +172,7 @@ internal class RoomEventCreatorTest {
@Test
fun `given edited event is older than related known timeline event then ignores edit`() = runTest {
val originalMessage = anApiTimelineTextEvent(utcTimestamp = 1000)
val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE)
val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -181,7 +183,7 @@ internal class RoomEventCreatorTest {
@Test
fun `given edited event is older than related room event then ignores edit`() = runTest {
val originalMessage = aMatrixRoomMessageEvent(utcTimestamp = 1000)
val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE)
val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -192,7 +194,7 @@ internal class RoomEventCreatorTest {
@Test
fun `given reply event with no relation then maps to new room message using the full body`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val replyEvent = anApiTimelineTextEvent().toReplyEvent(messageContent = A_TEXT_EVENT_MESSAGE)
val replyEvent = anApiTimelineTextEvent().toReplyEvent(messageContent = A_TEXT_EVENT_MESSAGE.asString())
println(replyEvent.content)
val result = with(roomEventCreator) { replyEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) }
@ -200,7 +202,7 @@ internal class RoomEventCreatorTest {
result shouldBeEqualTo aMatrixRoomMessageEvent(
eventId = replyEvent.id,
utcTimestamp = replyEvent.utcTimestamp,
content = replyEvent.asTextContent().body!!,
content = RichText.of(replyEvent.asTextContent().body!!),
author = A_SENDER,
)
}
@ -209,7 +211,7 @@ internal class RoomEventCreatorTest {
fun `given reply event which relates to a timeline event then maps to reply`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = anApiTimelineTextEvent(content = aTimelineTextEventContent(body = "message being replied to"))
val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE)
val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -218,7 +220,7 @@ internal class RoomEventCreatorTest {
replyingTo = aMatrixRoomMessageEvent(
eventId = originalMessage.id,
utcTimestamp = originalMessage.utcTimestamp,
content = originalMessage.asTextContent().body!!,
content = RichText.of(originalMessage.asTextContent().body!!),
author = A_SENDER,
),
message = aMatrixRoomMessageEvent(
@ -234,7 +236,7 @@ internal class RoomEventCreatorTest {
fun `given reply event which relates to a room event then maps to reply`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = aMatrixRoomMessageEvent()
val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE)
val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -254,7 +256,7 @@ internal class RoomEventCreatorTest {
fun `given reply event which relates to another room reply event then maps to reply with the reply's message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = aRoomReplyMessageEvent()
val replyMessage = (originalMessage.message as RoomEvent.Message).toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE)
val replyMessage = (originalMessage.message as RoomEvent.Message).toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }

@ -1,6 +1,7 @@
package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.asString
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomState
import fake.FakeMatrixLogger
@ -60,7 +61,7 @@ internal class RoomRefresherTest {
}
private fun RoomEvent.Message.asLastMessage() = aLastMessage(
this.content,
this.content.asString(),
this.utcTimestamp,
this.author,
)

@ -7,7 +7,7 @@ import app.dapk.st.matrix.sync.RoomEvent
fun aMatrixRoomMessageEvent(
eventId: EventId = anEventId(),
utcTimestamp: Long = 0L,
content: String = "message-content",
content: RichText = RichText.of("message-content"),
author: RoomMember = aRoomMember(),
meta: MessageMeta = MessageMeta.FromServer,
edited: Boolean = false,