From e13ce95b833c87cabef91e34f0fa475ed69f8c2d Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 23 Oct 2022 20:04:38 +0100 Subject: [PATCH 01/28] adding super naive html parsing for style options --- .../app/dapk/st/matrix/common/RichText.kt | 19 +++ .../sync/internal/sync/RichMessageParser.kt | 57 +++++++++ .../internal/sync/RichMessageParserTest.kt | 116 ++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt create mode 100644 matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt new file mode 100644 index 0000000..1db3d4e --- /dev/null +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt @@ -0,0 +1,19 @@ +package app.dapk.st.matrix.common + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RichText(@SerialName("parts") val parts: Set) { + @Serializable + sealed interface Part { + @Serializable + data class Normal(@SerialName("content") val content: String) : Part + data class Link(@SerialName("url") val url: String, @SerialName("label") val label: String) : Part + data class Bold(@SerialName("content") val content: String) : Part + data class Italic(@SerialName("content") val content: String) : Part + data class BoldItalic(@SerialName("content") val content: String) : Part + } +} + +fun RichText.asString() = parts.joinToString(separator = "") \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt new file mode 100644 index 0000000..8545842 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt @@ -0,0 +1,57 @@ +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) + +class RichMessageParser { + + fun parse(input: String): RichText { + val buffer = mutableSetOf() + var openIndex = 0 + var closeIndex = 0 + while (openIndex != -1) { + val foundIndex = input.indexOf('<', startIndex = openIndex) + if (foundIndex != -1) { + closeIndex = input.indexOf('>', startIndex = openIndex) + + 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)) + } + + "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 + } + } + + return RichText(buffer) + } + +} diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt new file mode 100644 index 0000000..2cbd6c1 --- /dev/null +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt @@ -0,0 +1,116 @@ +package app.dapk.st.matrix.sync.internal.sync + +import app.dapk.st.matrix.common.RichText +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Ignore +import org.junit.Test + +class RichMessageParserTest { + + private val parser = RichMessageParser() + + @Test + fun `parses plain text`() = runParserTest( + input = "Hello world!", + expected = RichText(setOf(RichText.Part.Normal("Hello world!"))) + ) + + @Test + fun `parses nested b tags`() = runParserTest( + Case( + input = """hello world""", + expected = RichText( + setOf( + RichText.Part.Normal("hello "), + RichText.Part.Bold("wor"), + RichText.Part.Normal("ld"), + ) + ) + ), + ) + + @Test + fun `parses nested i tags`() = runParserTest( + Case( + input = """hello world""", + expected = RichText( + setOf( + RichText.Part.Normal("hello "), + RichText.Part.Italic("wor"), + RichText.Part.Normal("ld"), + ) + ) + ), + ) + + @Ignore // TODO + @Test + fun `parses nested tags`() = runParserTest( + Case( + input = """hello world""", + expected = RichText( + setOf( + RichText.Part.Normal("hello "), + RichText.Part.BoldItalic("wor"), + RichText.Part.Normal("ld"), + ) + ) + ), + Case( + input = """www.google.com""", + 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"), + ) + ) + ) + ) + + @Test + fun `parses 'a' tags`() = runParserTest( + Case( + input = """hello world a link! 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."), + ) + ) + ), + Case( + input = """www.google.comwww.bing.com""", + 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"), + ) + ) + ), + ) + + private fun runParserTest(vararg cases: Case) { + val errors = mutableListOf() + cases.forEach { + runCatching { runParserTest(it.input, it.expected) }.onFailure { errors.add(it) } + } + if (errors.isNotEmpty()) { + throw CompositeThrowable(errors) + } + } + + private fun runParserTest(input: String, expected: RichText) { + val result = parser.parse(input) + + result shouldBeEqualTo expected + } +} + +private data class Case(val input: String, val expected: RichText) + +class CompositeThrowable(inner: List) : Throwable() { + init { + inner.forEach { addSuppressed(it) } + } +} \ No newline at end of file From fddcdaa50c959993a47186c982fa77257c7b0446 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 23 Oct 2022 23:15:46 +0100 Subject: [PATCH 02/28] adding custom html parsing with tag styling --- .../main/kotlin/app/dapk/st/engine/Models.kt | 2 +- .../main/kotlin/app/dapk/st/core/RichText.kt | 13 ++ .../app/dapk/st/design/components/Bubble.kt | 59 +++++-- .../dapk/st/domain/sync/RoomPersistence.kt | 5 +- .../app/dapk/st/messenger/MessengerScreen.kt | 18 ++- .../dapk/st/messenger/MessengerViewModel.kt | 6 +- .../RoomEventsToNotifiableMapper.kt | 3 +- .../app/dapk/st/engine/DirectoryUseCase.kt | 2 +- .../app/dapk/st/engine/SendMessageUseCase.kt | 5 +- .../app/dapk/st/matrix/common/RichText.kt | 22 ++- .../dapk/st/matrix/message/MessageService.kt | 4 +- .../message/internal/SendMessageUseCase.kt | 6 +- .../app/dapk/st/matrix/sync/RoomState.kt | 2 +- .../app/dapk/st/matrix/sync/SyncService.kt | 3 + .../sync/internal/DefaultSyncService.kt | 5 +- .../sync/internal/room/RoomEventsDecrypter.kt | 4 +- .../sync/internal/sync/RichMessageParser.kt | 145 ++++++++++++------ .../sync/internal/sync/RoomDataSource.kt | 2 +- .../sync/internal/sync/RoomEventCreator.kt | 4 +- .../sync/internal/sync/RoomEventFactory.kt | 7 +- .../sync/internal/sync/RoomProcessor.kt | 7 +- .../internal/room/RoomEventsDecrypterTest.kt | 5 +- .../internal/sync/EventLookupUseCaseTest.kt | 5 +- .../internal/sync/RichMessageParserTest.kt | 98 +++++++++--- .../internal/sync/RoomEventCreatorTest.kt | 38 ++--- .../sync/internal/sync/RoomRefresherTest.kt | 3 +- .../kotlin/fixture/RoomEventFixture.kt | 2 +- 27 files changed, 347 insertions(+), 128 deletions(-) create mode 100644 core/src/main/kotlin/app/dapk/st/core/RichText.kt diff --git a/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt b/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt index 5a29bff..7a6e25a 100644 --- a/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt +++ b/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt @@ -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, diff --git a/core/src/main/kotlin/app/dapk/st/core/RichText.kt b/core/src/main/kotlin/app/dapk/st/core/RichText.kt new file mode 100644 index 0000000..ee72339 --- /dev/null +++ b/core/src/main/kotlin/app/dapk/st/core/RichText.kt @@ -0,0 +1,13 @@ +package app.dapk.st.core + +data class RichText(val parts: Set) { + 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 = "") \ No newline at end of file diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt index 0a59935..46335b2 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt @@ -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( diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt index 5adf63e..7864bb7 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt @@ -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 diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index 6fe4ce1..843b7ee 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -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, diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt index 66de6e1..257599b 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt @@ -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 { diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt index ef649cd..e53d31c 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapper.kt @@ -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" } diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt index e21596f..897d026 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt @@ -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, diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/SendMessageUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/SendMessageUseCase.kt index 4c4054d..04fc601 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/SendMessageUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/SendMessageUseCase.kt @@ -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, diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt index 1db3d4e..148ea81 100644 --- a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt @@ -9,11 +9,31 @@ data class RichText(@SerialName("parts") val parts: Set) { 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 = "") \ No newline at end of file +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 + } +} \ No newline at end of file diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt index 3a39d8b..063e1b4 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt @@ -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() diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt index 0703d8d..1f30366 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt @@ -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" ) } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt index 837066d..98ccab6 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/RoomState.kt @@ -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, diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt index 6cba46e..c6923a0 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt @@ -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, errorTracker: ErrorTracker, coroutineDispatchers: CoroutineDispatchers, + syncConfig: SyncConfig = SyncConfig(), ): InstallExtender { this.serializers { @@ -96,6 +98,7 @@ fun MatrixServiceInstaller.installSyncService( errorTracker = errorTracker, coroutineDispatchers = coroutineDispatchers, syncConfig = syncConfig, + richMessageParser = RichMessageParser() ) } } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt index fa7e04f..bb446d1 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt @@ -41,13 +41,14 @@ internal class DefaultSyncService( errorTracker: ErrorTracker, private val coroutineDispatchers: CoroutineDispatchers, syncConfig: SyncConfig, + richMessageParser: RichMessageParser, ) : SyncService { private val syncEventsFlow = MutableStateFlow>(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) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt index 1194b74..0f88ba5 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt @@ -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( diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt index 8545842..2417899 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt @@ -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() - 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() + 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 + "
".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 = "" + 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() } } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt index f3d7e6f..aecbe6b 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomDataSource.kt @@ -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 { diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt index 2bb35af..5c7b5d2 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt @@ -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() } } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt index 2abbd45..bfae621 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt @@ -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, diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt index 9691686..ef90cb8 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomProcessor.kt @@ -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.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" } \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt index 4283146..3aca409 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt @@ -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, diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCaseTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCaseTest.kt index 5bc47b6..0073e7c 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCaseTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/EventLookupUseCaseTest.kt @@ -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 { diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt index 2cbd6c1..670192c 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt @@ -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!

foo bar

after paragraph", + expected = RichText(setOf(Normal("Hello world! "), Normal("foo bar"), Normal(" after paragraph"))) + ) + + @Test + fun `skips header tags`() = runParserTest( Case( - input = """hello world""", + input = "

hello

", + expected = RichText(setOf(Normal("hello"))) + ), + Case( + input = "

hello

", + expected = RichText(setOf(Normal("hello"))) + ), + Case( + input = "

hello

", + expected = RichText(setOf(Normal("hello"))) + ), + ) + + @Test + fun `replaces br tags`() = runParserTest( + input = "Hello world!
next line
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 = "hello world", + 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 world""", 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 world""", + input = """hello world""", 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 world""", 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 = """
www.google.com""", 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 link! more content.""", + input = """hello world a link! 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 = """www.google.comwww.bing.com""", + input = """www.google.comwww.bing.com""", 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"), ) ) ), diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt index 38f1ed1..7328471 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt @@ -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) } diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt index b0b1ef3..2b9e052 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomRefresherTest.kt @@ -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, ) diff --git a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt index 36bdf0b..4228454 100644 --- a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt +++ b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomEventFixture.kt @@ -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, From 9476fc58140f3f6fb5b633c67e3d7869c7f15ae2 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 24 Oct 2022 22:03:53 +0100 Subject: [PATCH 03/28] handling more text parsing cases for fallbacks and urls --- .../main/kotlin/app/dapk/st/core/RichText.kt | 1 + .../app/dapk/st/design/components/Bubble.kt | 14 ++-- .../app/dapk/st/messenger/MessengerScreen.kt | 1 + .../app/dapk/st/matrix/common/RichText.kt | 4 ++ .../sync/internal/sync/RichMessageParser.kt | 48 ++++++++++--- .../internal/sync/RichMessageParserTest.kt | 68 ++++++++++++++++--- 6 files changed, 112 insertions(+), 24 deletions(-) diff --git a/core/src/main/kotlin/app/dapk/st/core/RichText.kt b/core/src/main/kotlin/app/dapk/st/core/RichText.kt index ee72339..8cff054 100644 --- a/core/src/main/kotlin/app/dapk/st/core/RichText.kt +++ b/core/src/main/kotlin/app/dapk/st/core/RichText.kt @@ -7,6 +7,7 @@ data class RichText(val parts: Set) { data class Bold(val content: String) : Part data class Italic(val content: String) : Part data class BoldItalic(val content: String) : Part + data class Person(val userId: String) : Part } } diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt index 46335b2..91506a4 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt @@ -17,14 +17,11 @@ 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.* 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 @@ -265,6 +262,10 @@ val hyperLinkStyle = SpanStyle( textDecoration = TextDecoration.Underline ) +val nameStyle = SpanStyle( + color = Color(0xff64B5F6), +) + fun RichText.toAnnotatedText() = buildAnnotatedString { parts.forEach { when (it) { @@ -278,6 +279,9 @@ fun RichText.toAnnotatedText() = buildAnnotatedString { } is RichText.Part.Normal -> append(it.content) + is RichText.Part.Person -> withStyle(nameStyle) { + append("@${it.userId.substringBefore(':').removePrefix("@")}") + } } } } @@ -295,4 +299,4 @@ private fun AuthorName(event: BubbleModel.Event, bubble: BubbleMeta) { @Composable private fun BubbleMeta.textColor(): Color { return if (this.isSelf) SmallTalkTheme.extendedColors.onSelfBubble else SmallTalkTheme.extendedColors.onOthersBubble -} \ No newline at end of file +} diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index 843b7ee..a19c109 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -234,6 +234,7 @@ private fun RichText.toApp(): app.dapk.st.core.RichText { 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) + is RichText.Part.Person -> app.dapk.st.core.RichText.Part.Person(it.userId.value) } }.toSet()) } diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt index 148ea81..b6f624c 100644 --- a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt @@ -21,6 +21,9 @@ data class RichText(@SerialName("parts") val parts: Set) { @Serializable data class BoldItalic(@SerialName("content") val content: String) : Part + + @Serializable + data class Person(@SerialName("user_id") val userId: UserId, @SerialName("display_name") val displayName: String) : Part } companion object { @@ -35,5 +38,6 @@ fun RichText.asString() = parts.joinToString(separator = "") { is RichText.Part.Italic -> it.content is RichText.Part.Link -> it.label is RichText.Part.Normal -> it.content + is RichText.Part.Person -> it.userId.value } } \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt index 2417899..e65e9b4 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt @@ -2,10 +2,14 @@ package app.dapk.st.matrix.sync.internal.sync import app.dapk.st.matrix.common.RichText import app.dapk.st.matrix.common.RichText.Part.* +import app.dapk.st.matrix.common.UserId class RichMessageParser { - fun parse(input: String): RichText { + fun parse(source: String): RichText { + val input = source + .removeHtmlEntities() + .dropTextFallback() return kotlin.runCatching { val buffer = mutableSetOf() var openIndex = 0 @@ -21,12 +25,23 @@ class RichMessageParser { val wholeTag = input.substring(foundIndex, closeIndex + 1) val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' }) + if (tagName.startsWith('@')) { + if (openIndex != foundIndex) { + buffer.add(Normal(input.substring(openIndex, foundIndex))) + } + buffer.add(Person(UserId(tagName), tagName)) + println(tagName) + openIndex = foundIndex + wholeTag.length + lastStartIndex = openIndex + continue + } + if (tagName == "br") { if (openIndex != foundIndex) { buffer.add(Normal(input.substring(openIndex, foundIndex))) } buffer.add(Normal("\n")) - openIndex = foundIndex + "
".length + openIndex = foundIndex + wholeTag.length lastStartIndex = openIndex continue } @@ -39,6 +54,14 @@ class RichMessageParser { if (exitIndex == -1) { openIndex++ } else { + when (tagName) { + "mx-reply" -> { + openIndex = exitIndex + exitTag.length + lastStartIndex = openIndex + continue + } + } + if (openIndex != foundIndex) { buffer.add(Normal(input.substring(openIndex, foundIndex))) } @@ -49,7 +72,16 @@ class RichMessageParser { when (tagName) { "a" -> { val findHrefUrl = wholeTag.substringAfter("href=").replace("\"", "").removeSuffix(">") - buffer.add(Link(url = findHrefUrl, label = tagContent)) + if (findHrefUrl.startsWith("https://matrix.to/#/@")) { + val userId = UserId(findHrefUrl.substringAfter("https://matrix.to/#/").substringBeforeLast("\"")) + buffer.add(Person(userId, "@${tagContent.removePrefix("@")}")) + if (input.getOrNull(openIndex) == ':') { + openIndex++ + lastStartIndex = openIndex + } + } else { + buffer.add(Link(url = findHrefUrl, label = tagContent)) + } } "b" -> buffer.add(Bold(tagContent)) @@ -84,12 +116,6 @@ class RichMessageParser { 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 @@ -114,3 +140,7 @@ class RichMessageParser { } } + +private fun String.removeHtmlEntities() = this.replace(""", "\"").replace("'", "'") + +private fun String.dropTextFallback() = this.lines().dropWhile { it.startsWith("> ") || it.isEmpty() }.joinToString("") diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt index 670192c..22f122d 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt @@ -1,8 +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 app.dapk.st.matrix.common.RichText.Part.* +import fixture.aUserId import org.amshove.kluent.shouldBeEqualTo import org.junit.Ignore import org.junit.Test @@ -23,6 +23,30 @@ class RichMessageParserTest { expected = RichText(setOf(Normal("Hello world! "), Normal("foo bar"), Normal(" after paragraph"))) ) + @Test + fun `replaces quote entity`() = runParserTest( + input = "Hello world! "foo bar"", + expected = RichText(setOf(Normal("Hello world! \"foo bar\""))) + ) + + @Test + fun `replaces apostrophe entity`() = runParserTest( + input = "Hello world! foo's bar", + expected = RichText(setOf(Normal("Hello world! foo's bar"))) + ) + + @Test + fun `replaces people`() = runParserTest( + input = "Hello <@my-name:a-domain.foo>!", + expected = RichText(setOf(Normal("Hello "), Person(aUserId("@my-name:a-domain.foo"), "@my-name:a-domain.foo"), Normal("!"))) + ) + + @Test + fun `replaces matrixdotto with person`() = runParserTest( + input = """Hello +
+ Original message +
+ + Reply to message + """.trimIndent(), + expected = RichText(setOf(Normal("Reply to message"))) ) @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"))) + fun `removes text fallback`() = runParserTest( + input = """ + > <@user:domain.foo> Original message + > Some more content + + Reply to message + """.trimIndent(), + expected = RichText(setOf(Normal("Reply to message"))) + ) + + @Test + fun `parses styling text`() = runParserTest( + input = "hello world", + expected = RichText(setOf(Italic("hello"), Normal(" "), Bold("world"))) + ) + + @Test + fun `parses invalid tags text`() = runParserTest( + input = ">> ><>> << more content", + expected = RichText(setOf(Normal(">> ><>> << more content"))) ) @Test @@ -80,7 +128,7 @@ class RichMessageParserTest { expected = RichText( setOf( Normal("hello "), - RichText.Part.Bold("wor"), + Bold("wor"), Normal("ld"), ) ) @@ -94,7 +142,7 @@ class RichMessageParserTest { expected = RichText( setOf( Normal("hello "), - RichText.Part.Italic("wor"), + Italic("wor"), Normal("ld"), ) ) From 87f25e6e66174c2a2c30139b3fb81eeca9f92d91 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 24 Oct 2022 22:30:22 +0100 Subject: [PATCH 04/28] removing previous tag stripping --- .../testFixtures/kotlin/fixture/Fixtures.kt | 4 +-- .../main/kotlin/app/dapk/st/core/RichText.kt | 13 +++++-- .../app/dapk/st/design/components/Bubble.kt | 2 +- .../sync/internal/sync/RoomEventCreator.kt | 8 +++-- .../sync/internal/sync/RoomEventFactory.kt | 34 ------------------- 5 files changed, 20 insertions(+), 41 deletions(-) diff --git a/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt b/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt index 68f252b..b3c903f 100644 --- a/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt +++ b/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt @@ -23,7 +23,7 @@ fun aRoomOverview( fun anEncryptedRoomMessageEvent( eventId: EventId = anEventId(), utcTimestamp: Long = 0L, - content: String = "encrypted-content", + content: RichText = RichText.of("encrypted-content"), author: RoomMember = aRoomMember(), meta: MessageMeta = MessageMeta.FromServer, edited: Boolean = false, @@ -47,7 +47,7 @@ fun aRoomReplyMessageEvent( fun aRoomMessageEvent( 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, diff --git a/core/src/main/kotlin/app/dapk/st/core/RichText.kt b/core/src/main/kotlin/app/dapk/st/core/RichText.kt index 8cff054..18206d0 100644 --- a/core/src/main/kotlin/app/dapk/st/core/RichText.kt +++ b/core/src/main/kotlin/app/dapk/st/core/RichText.kt @@ -7,8 +7,17 @@ data class RichText(val parts: Set) { data class Bold(val content: String) : Part data class Italic(val content: String) : Part data class BoldItalic(val content: String) : Part - data class Person(val userId: String) : Part + data class Person(val displayName: String) : Part } } -fun RichText.asString() = parts.joinToString(separator = "") \ No newline at end of file +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 + is RichText.Part.Person -> it.displayName + } +} \ No newline at end of file diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt index 91506a4..3a8c5e7 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt @@ -280,7 +280,7 @@ fun RichText.toAnnotatedText() = buildAnnotatedString { is RichText.Part.Normal -> append(it.content) is RichText.Part.Person -> withStyle(nameStyle) { - append("@${it.userId.substringBefore(':').removePrefix("@")}") + append("@${it.displayName.substringBefore(':').removePrefix("@")}") } } } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt index 5c7b5d2..e6ddb29 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt @@ -156,13 +156,17 @@ 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, content = source.asTextContent().formattedBody ?: source.content.body ?: "") + is ApiTimelineEvent.TimelineMessage.Content.Text -> source.toTextMessage( + roomId, + content = source.asTextContent().formattedBody ?: source.content.body ?: "" + ) + ApiTimelineEvent.TimelineMessage.Content.Ignored -> throw IllegalStateException() } } private suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage( - content: String = this.asTextContent().formattedBody?.stripTags() ?: this.asTextContent().body ?: "redacted", + content: String = this.asTextContent().formattedBody ?: this.asTextContent().body ?: "redacted", edited: Boolean = false, utcTimestamp: Long = this.utcTimestamp, ) = with(roomEventFactory) { toTextMessage(roomId, content, edited, utcTimestamp) } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt index bfae621..c8b1f10 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt @@ -53,37 +53,3 @@ internal class RoomEventFactory( ) } } - - -private fun String.indexOfOrNull(string: String) = this.indexOf(string).takeIf { it != -1 } - -fun String.stripTags() = this - .run { - this.indexOfOrNull("")?.let { - this.substring(it + "".length) - } ?: this - } - .trim() - .replaceLinks() - .removeTag("p") - .removeTag("em") - .removeTag("strong") - .removeTag("code") - .removeTag("pre") - .replace(""", "\"") - .replace("'", "'") - .replace("
", "\n") - .replace("
", "\n") - -private fun String.removeTag(name: String) = this.replace("<$name>", "").replace("/$name>", "") - -private fun String.replaceLinks(): String { - return this.indexOfOrNull("
")!! - val end = indexOfOrNull("")!! - val content = this.substring(openTagClose + "\">".length, end) - this.replaceRange(start, end + "".length, content) - } ?: this -} - -private fun ApiTimelineEvent.TimelineMessage.asTextContent() = this.content as ApiTimelineEvent.TimelineMessage.Content.Text From e79c5c9a27282b2c0cf7af6088c937b55f6de97e Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 24 Oct 2022 22:47:35 +0100 Subject: [PATCH 05/28] handling edits with rich text (kind of~) --- .../dapk/st/matrix/sync/internal/DefaultSyncService.kt | 2 +- .../st/matrix/sync/internal/sync/RoomEventCreator.kt | 10 ++++++---- .../matrix/sync/internal/sync/RoomEventCreatorTest.kt | 6 +++++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt index bb446d1..907388c 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt @@ -58,7 +58,7 @@ internal class DefaultSyncService( roomMembersService, roomDataSource, TimelineEventsProcessor( - RoomEventCreator(roomMembersService, errorTracker, RoomEventFactory(roomMembersService, richMessageParser)), + RoomEventCreator(roomMembersService, errorTracker, RoomEventFactory(roomMembersService, richMessageParser), richMessageParser), roomEventsDecrypter, eventDecrypter, EventLookupUseCase(roomStore) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt index e6ddb29..72d9a46 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt @@ -19,6 +19,7 @@ internal class RoomEventCreator( private val roomMembersService: RoomMembersService, private val errorTracker: ErrorTracker, private val roomEventFactory: RoomEventFactory, + private val richMessageParser: RichMessageParser, ) { suspend fun ApiTimelineEvent.Encrypted.toRoomEvent(roomId: RoomId): RoomEvent? { @@ -44,7 +45,7 @@ internal class RoomEventCreator( } suspend fun ApiTimelineEvent.TimelineMessage.toRoomEvent(userCredentials: UserCredentials, roomId: RoomId, lookup: Lookup): RoomEvent? { - return TimelineEventMapper(userCredentials, roomId, roomEventFactory).mapToRoomEvent(this, lookup) + return TimelineEventMapper(userCredentials, roomId, roomEventFactory, richMessageParser).mapToRoomEvent(this, lookup) } } @@ -52,6 +53,7 @@ internal class TimelineEventMapper( private val userCredentials: UserCredentials, private val roomId: RoomId, private val roomEventFactory: RoomEventFactory, + private val richMessageParser: RichMessageParser, ) { suspend fun mapToRoomEvent(event: ApiTimelineEvent.TimelineMessage, lookup: Lookup): RoomEvent? { @@ -138,7 +140,7 @@ internal class TimelineEventMapper( is ApiTimelineEvent.TimelineMessage.Content.Text -> original.toTextMessage( utcTimestamp = incomingEdit.utcTimestamp, - content = incomingEdit.asTextContent().body?.removePrefix(" * ")?.trim() ?: "redacted", + content = incomingEdit.asTextContent().let { it.formattedBody ?: it.body }?.removePrefix(" * ") ?: "redacted", edited = true, ) @@ -147,8 +149,8 @@ internal class TimelineEventMapper( } } - // TODO handle edits private fun RoomEvent.Message.edited(edit: ApiTimelineEvent.TimelineMessage) = this.copy( + content = richMessageParser.parse(edit.asTextContent().let { it.formattedBody ?: it.body }?.removePrefix(" * ") ?: "redacted"), utcTimestamp = edit.utcTimestamp, edited = true, ) @@ -158,7 +160,7 @@ internal class TimelineEventMapper( is ApiTimelineEvent.TimelineMessage.Content.Image -> source.toImageMessage(userCredentials, roomId) is ApiTimelineEvent.TimelineMessage.Content.Text -> source.toTextMessage( roomId, - content = source.asTextContent().formattedBody ?: source.content.body ?: "" + content = source.asTextContent().formattedBody ?: source.content.body ?: "redacted" ) ApiTimelineEvent.TimelineMessage.Content.Ignored -> throw IllegalStateException() diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt index 7328471..a2d5cc7 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt @@ -33,7 +33,11 @@ internal class RoomEventCreatorTest { private val fakeRoomMembersService = FakeRoomMembersService() - private val roomEventCreator = RoomEventCreator(fakeRoomMembersService, FakeErrorTracker(), RoomEventFactory(fakeRoomMembersService, RichMessageParser())) + private val richMessageParser = RichMessageParser() + private val roomEventCreator = RoomEventCreator( + fakeRoomMembersService, FakeErrorTracker(), RoomEventFactory(fakeRoomMembersService, richMessageParser), + richMessageParser + ) @Test fun `given Megolm encrypted event then maps to encrypted room message`() = runTest { From eeb13b3f6ceb4e5f200412e2a5844ab1706b963f Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 24 Oct 2022 22:56:17 +0100 Subject: [PATCH 06/28] adding missing rich text test usages --- .../st/notifications/RoomEventsToNotifiableMapperTest.kt | 8 +++++--- .../app/dapk/st/engine/MergeWithLocalEchosUseCaseTest.kt | 3 ++- .../engine/ObserveUnreadRenderNotificationsUseCaseTest.kt | 5 +++-- .../test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt | 3 ++- .../src/testFixtures/kotlin/fixture/MessageFixture.kt | 3 ++- test-harness/src/test/kotlin/test/Test.kt | 6 ++++-- 6 files changed, 18 insertions(+), 10 deletions(-) diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapperTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapperTest.kt index 11d8241..b128732 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapperTest.kt +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RoomEventsToNotifiableMapperTest.kt @@ -1,5 +1,7 @@ package app.dapk.st.notifications +import app.dapk.st.matrix.common.RichText +import app.dapk.st.matrix.common.asString import fixture.aRoomImageMessageEvent import fixture.aRoomMessageEvent import fixture.aRoomReplyMessageEvent @@ -18,7 +20,7 @@ class RoomEventsToNotifiableMapperTest { result shouldBeEqualTo listOf( Notifiable( - content = event.content, + content = event.content.asString(), utcTimestamp = event.utcTimestamp, author = event.author ) @@ -42,14 +44,14 @@ class RoomEventsToNotifiableMapperTest { @Test fun `given reply event with message, when mapping, then uses message for content`() { - val reply = aRoomMessageEvent(utcTimestamp = -1, content = "hello") + val reply = aRoomMessageEvent(utcTimestamp = -1, content = RichText.of("hello")) val event = aRoomReplyMessageEvent(reply, replyingTo = aRoomImageMessageEvent(utcTimestamp = -1)) val result = mapper.map(listOf(event)) result shouldBeEqualTo listOf( Notifiable( - content = reply.content, + content = reply.content.asString(), utcTimestamp = event.utcTimestamp, author = event.author ) diff --git a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCaseTest.kt index 5603193..4868445 100644 --- a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCaseTest.kt +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/MergeWithLocalEchosUseCaseTest.kt @@ -1,6 +1,7 @@ package app.dapk.st.engine import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RichText import app.dapk.st.matrix.message.MessageService import fixture.* import org.amshove.kluent.shouldBeEqualTo @@ -54,7 +55,7 @@ class MergeWithLocalEchosUseCaseTest { private fun createLocalEcho(eventId: EventId, body: String, state: MessageService.LocalEcho.State) = aLocalEcho( eventId, - aTextMessage(aTextContent(body)), + aTextMessage(aTextContent(RichText.of(body))), state, ) } \ No newline at end of file diff --git a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ObserveUnreadRenderNotificationsUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ObserveUnreadRenderNotificationsUseCaseTest.kt index 50bd2b9..075de8e 100644 --- a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ObserveUnreadRenderNotificationsUseCaseTest.kt +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ObserveUnreadRenderNotificationsUseCaseTest.kt @@ -1,5 +1,6 @@ package app.dapk.st.engine +import app.dapk.st.matrix.common.RichText import fake.FakeRoomStore import fixture.NotificationDiffFixtures.aNotificationDiff import fixture.aMatrixRoomMessageEvent @@ -15,8 +16,8 @@ import app.dapk.st.matrix.sync.RoomEvent as MatrixRoomEvent import app.dapk.st.matrix.sync.RoomOverview as MatrixRoomOverview private val NO_UNREADS = emptyMap>() -private val A_MESSAGE = aMatrixRoomMessageEvent(eventId = anEventId("1"), content = "hello", utcTimestamp = 1000) -private val A_MESSAGE_2 = aMatrixRoomMessageEvent(eventId = anEventId("2"), content = "world", utcTimestamp = 2000) +private val A_MESSAGE = aMatrixRoomMessageEvent(eventId = anEventId("1"), content = RichText.of("hello"), utcTimestamp = 1000) +private val A_MESSAGE_2 = aMatrixRoomMessageEvent(eventId = anEventId("2"), content = RichText.of("world"), utcTimestamp = 2000) private val A_ROOM_OVERVIEW = aMatrixRoomOverview(roomId = aRoomId("1")) private val A_ROOM_OVERVIEW_2 = aMatrixRoomOverview(roomId = aRoomId("2")) diff --git a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt index c2edd7d..ae5b47e 100644 --- a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt @@ -1,5 +1,6 @@ package app.dapk.st.engine +import app.dapk.st.matrix.common.RichText import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.common.UserId @@ -24,7 +25,7 @@ import test.delegateReturn private val A_ROOM_ID = aRoomId() private val AN_USER_ID = aUserId() private val A_ROOM_STATE = aMatrixRoomState() -private val A_MERGED_ROOM_STATE = A_ROOM_STATE.copy(events = listOf(aMatrixRoomMessageEvent(content = "a merged event"))) +private val A_MERGED_ROOM_STATE = A_ROOM_STATE.copy(events = listOf(aMatrixRoomMessageEvent(content = RichText.of("a merged event")))) private val A_LOCAL_ECHOS_LIST = listOf(aLocalEcho()) private val A_ROOM_MEMBER = aRoomMember() diff --git a/matrix/services/message/src/testFixtures/kotlin/fixture/MessageFixture.kt b/matrix/services/message/src/testFixtures/kotlin/fixture/MessageFixture.kt index 79c9e57..479b696 100644 --- a/matrix/services/message/src/testFixtures/kotlin/fixture/MessageFixture.kt +++ b/matrix/services/message/src/testFixtures/kotlin/fixture/MessageFixture.kt @@ -1,6 +1,7 @@ package fixture import app.dapk.st.matrix.common.MessageType +import app.dapk.st.matrix.common.RichText import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.message.MessageService @@ -13,6 +14,6 @@ fun aTextMessage( ) = MessageService.Message.TextMessage(content, sendEncrypted, roomId, localId, timestampUtc) fun aTextContent( - body: String = "text content body", + body: RichText = RichText.of("text content body"), type: String = MessageType.TEXT.value, ) = MessageService.Message.Content.TextContent(body, type) diff --git a/test-harness/src/test/kotlin/test/Test.kt b/test-harness/src/test/kotlin/test/Test.kt index e0403a2..731927c 100644 --- a/test-harness/src/test/kotlin/test/Test.kt +++ b/test-harness/src/test/kotlin/test/Test.kt @@ -5,8 +5,10 @@ package test import TestMessage import TestUser import app.dapk.st.core.extensions.ifNull +import app.dapk.st.matrix.common.RichText import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.common.asString import app.dapk.st.matrix.crypto.MatrixMediaDecrypter import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.messageService @@ -138,7 +140,7 @@ class MatrixTestScope(private val testScope: TestScope) { suspend fun TestMatrix.expectTextMessage(roomId: RoomId, message: TestMessage) { println("expecting ${message.content}") this.client.syncService().room(roomId) - .map { it.events.filterIsInstance().map { TestMessage(it.content, it.author) }.firstOrNull() } + .map { it.events.filterIsInstance().map { TestMessage(it.content.asString(), it.author) }.firstOrNull() } .assert(message) } @@ -170,7 +172,7 @@ class MatrixTestScope(private val testScope: TestScope) { println("sending $content") this.client.messageService().scheduleMessage( MessageService.Message.TextMessage( - content = MessageService.Message.Content.TextContent(body = content), + content = MessageService.Message.Content.TextContent(body = RichText.of(content)), roomId = roomId, sendEncrypted = isEncrypted, localId = "local.${UUID.randomUUID()}", From 10fc9f73833ca1a61ce776f02196bc0b884deae0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Oct 2022 05:27:21 +0000 Subject: [PATCH 07/28] Bump accompanist-systemuicontroller from 0.25.1 to 0.27.0 Bumps [accompanist-systemuicontroller](https://github.com/google/accompanist) from 0.25.1 to 0.27.0. - [Release notes](https://github.com/google/accompanist/releases) - [Commits](https://github.com/google/accompanist/compare/v0.25.1...v0.27.0) --- updated-dependencies: - dependency-name: com.google.accompanist:accompanist-systemuicontroller dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index 420d9b9..b4ca17c 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -143,7 +143,7 @@ ext.Dependencies.with { ktorContentNegotiation = "io.ktor:ktor-client-content-negotiation:${ktorVer}" coil = "io.coil-kt:coil-compose:2.2.2" - accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.25.1" + accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.27.0" junit = "junit:junit:4.13.2" kluent = "org.amshove.kluent:kluent:1.72" From 45962157f0265d6f7ad04d18cd186102098ac525 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 28 Oct 2022 16:24:53 +0100 Subject: [PATCH 08/28] ensuring all rich text usages use asString exit --- .../dapk/st/matrix/message/internal/SendMessageUseCase.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt index 1f30366..f070232 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt @@ -153,13 +153,13 @@ class ApiMessageMapper { fun Message.TextMessage.toContents(reply: Message.TextMessage.Reply?) = when (reply) { null -> ApiMessage.TextMessage.TextContent( - body = this.content.body.parts.joinToString(""), + body = this.content.body.asString(), ) else -> ApiMessage.TextMessage.TextContent( - body = buildReplyFallback(reply.originalMessage.parts.joinToString(""), reply.author.id, reply.replyContent), + body = buildReplyFallback(reply.originalMessage.asString(), reply.author.id, reply.replyContent), relatesTo = ApiMessage.RelatesTo(ApiMessage.RelatesTo.InReplyTo(reply.eventId)), - formattedBody = buildFormattedReply(reply.author.id, reply.originalMessage.parts.joinToString(""), reply.replyContent, this.roomId, reply.eventId), + formattedBody = buildFormattedReply(reply.author.id, reply.originalMessage.asString(), reply.replyContent, this.roomId, reply.eventId), format = "org.matrix.custom.html" ) } From f694ffe786f2f2d110d9c7249c5568a760aa8f47 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 28 Oct 2022 17:35:30 +0100 Subject: [PATCH 09/28] add best guess end to url parsing --- .../sync/internal/sync/RichMessageParser.kt | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt index e65e9b4..a230e59 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt @@ -4,6 +4,8 @@ import app.dapk.st.matrix.common.RichText import app.dapk.st.matrix.common.RichText.Part.* import app.dapk.st.matrix.common.UserId +private const val INVALID_TRAILING_CHARS = ",.:;?" + class RichMessageParser { fun parse(source: String): RichText { @@ -30,7 +32,6 @@ class RichMessageParser { buffer.add(Normal(input.substring(openIndex, foundIndex))) } buffer.add(Person(UserId(tagName), tagName)) - println(tagName) openIndex = foundIndex + wholeTag.length lastStartIndex = openIndex continue @@ -48,9 +49,6 @@ class RichMessageParser { val exitTag = "" val exitIndex = input.indexOf(exitTag, startIndex = closeIndex) - - println("$exitTag : $exitIndex") - if (exitIndex == -1) { openIndex++ } else { @@ -101,24 +99,24 @@ class RichMessageParser { buffer.add(Normal(input.substring(lastStartIndex, urlIndex))) } - val substring1 = input.substring(urlIndex) - val urlEndIndex = substring1.indexOfFirst { it == '\n' || it == ' ' } + val originalUrl = input.substring(urlIndex) + val urlEndIndex = originalUrl.indexOfFirst { it == '\n' || it == ' ' } + val urlContinuesUntilEnd = urlEndIndex == -1 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())) + urlContinuesUntilEnd -> { + val cleanedUrl = originalUrl.bestGuessStripTrailingUrlChar() + buffer.add(Link(url = cleanedUrl, label = cleanedUrl)) + if (cleanedUrl != originalUrl) { + buffer.add(Normal(originalUrl.last().toString())) } break } else -> { - val substring = input.substring(urlIndex, urlEndIndex) - val url = substring.removeSuffix(".").removeSuffix(",") - buffer.add(Link(url = url, label = url)) - openIndex = if (substring.endsWith('.') || substring.endsWith(',')) urlEndIndex - 1 else urlEndIndex + val originalUrl = input.substring(urlIndex, urlEndIndex) + val cleanedUrl = originalUrl.bestGuessStripTrailingUrlChar() + buffer.add(Link(url = cleanedUrl, label = cleanedUrl)) + openIndex = if (originalUrl == cleanedUrl) urlEndIndex else urlEndIndex - 1 lastStartIndex = openIndex continue } @@ -143,4 +141,13 @@ class RichMessageParser { private fun String.removeHtmlEntities() = this.replace(""", "\"").replace("'", "'") -private fun String.dropTextFallback() = this.lines().dropWhile { it.startsWith("> ") || it.isEmpty() }.joinToString("") +private fun String.dropTextFallback() = this.lines().dropWhile { it.startsWith("> ") || it.isEmpty() }.joinToString("\n") + +private fun String.bestGuessStripTrailingUrlChar(): String { + val last = this.last() + return if (INVALID_TRAILING_CHARS.contains(last)) { + this.dropLast(1) + } else { + this + } +} \ No newline at end of file From 89af610f58df766c8074d8d9ba3f3874b1ea59be Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 28 Oct 2022 18:05:37 +0100 Subject: [PATCH 10/28] optimise plain text appending to avoid creating extra instances --- .../sync/internal/sync/RichMessageParser.kt | 276 ++++++++++-------- .../internal/sync/RichMessageParserTest.kt | 4 +- 2 files changed, 162 insertions(+), 118 deletions(-) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt index a230e59..692d280 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt @@ -5,6 +5,8 @@ import app.dapk.st.matrix.common.RichText.Part.* import app.dapk.st.matrix.common.UserId private const val INVALID_TRAILING_CHARS = ",.:;?" +private const val TAG_OPEN = '<' +private const val TAG_CLOSE = '>' class RichMessageParser { @@ -12,136 +14,132 @@ class RichMessageParser { val input = source .removeHtmlEntities() .dropTextFallback() - return kotlin.runCatching { - val buffer = mutableSetOf() - 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) { + val builder = PartBuilder() + var openIndex = 0 + var closeIndex = 0 + var lastStartIndex = 0 + while (openIndex != -1) { + val foundIndex = input.indexOf(TAG_OPEN, startIndex = openIndex) + if (foundIndex != -1) { + closeIndex = input.indexOf(TAG_CLOSE, startIndex = foundIndex) + if (closeIndex == -1) { + openIndex++ + } else { + val wholeTag = input.substring(foundIndex, closeIndex + 1) + val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' }) + + if (tagName.startsWith('@')) { + if (openIndex != foundIndex) { + builder.appendText(input.substring(openIndex, foundIndex)) + } + builder.appendPerson(UserId(tagName), tagName) + openIndex = foundIndex + wholeTag.length + lastStartIndex = openIndex + continue + } + + if (tagName == "br") { + if (openIndex != foundIndex) { + builder.appendText(input.substring(openIndex, foundIndex)) + } + builder.appendText("\n") + openIndex = foundIndex + wholeTag.length + lastStartIndex = openIndex + continue + } + + val exitTag = "" + val exitIndex = input.indexOf(exitTag, startIndex = closeIndex) + if (exitIndex == -1) { openIndex++ } else { - val wholeTag = input.substring(foundIndex, closeIndex + 1) - val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' }) - - if (tagName.startsWith('@')) { - if (openIndex != foundIndex) { - buffer.add(Normal(input.substring(openIndex, foundIndex))) - } - buffer.add(Person(UserId(tagName), tagName)) - openIndex = foundIndex + wholeTag.length - lastStartIndex = openIndex - continue - } - - if (tagName == "br") { - if (openIndex != foundIndex) { - buffer.add(Normal(input.substring(openIndex, foundIndex))) - } - buffer.add(Normal("\n")) - openIndex = foundIndex + wholeTag.length - lastStartIndex = openIndex - continue - } - - val exitTag = "" - val exitIndex = input.indexOf(exitTag, startIndex = closeIndex) - if (exitIndex == -1) { - openIndex++ - } else { - when (tagName) { - "mx-reply" -> { - openIndex = exitIndex + exitTag.length - lastStartIndex = openIndex - continue - } - } - - 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(">") - if (findHrefUrl.startsWith("https://matrix.to/#/@")) { - val userId = UserId(findHrefUrl.substringAfter("https://matrix.to/#/").substringBeforeLast("\"")) - buffer.add(Person(userId, "@${tagContent.removePrefix("@")}")) - if (input.getOrNull(openIndex) == ':') { - openIndex++ - lastStartIndex = openIndex - } - } else { - 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 originalUrl = input.substring(urlIndex) - val urlEndIndex = originalUrl.indexOfFirst { it == '\n' || it == ' ' } - val urlContinuesUntilEnd = urlEndIndex == -1 - when { - urlContinuesUntilEnd -> { - val cleanedUrl = originalUrl.bestGuessStripTrailingUrlChar() - buffer.add(Link(url = cleanedUrl, label = cleanedUrl)) - if (cleanedUrl != originalUrl) { - buffer.add(Normal(originalUrl.last().toString())) - } - break - } - - else -> { - val originalUrl = input.substring(urlIndex, urlEndIndex) - val cleanedUrl = originalUrl.bestGuessStripTrailingUrlChar() - buffer.add(Link(url = cleanedUrl, label = cleanedUrl)) - openIndex = if (originalUrl == cleanedUrl) urlEndIndex else urlEndIndex - 1 + when (tagName) { + "mx-reply" -> { + openIndex = exitIndex + exitTag.length lastStartIndex = openIndex continue } } - } - // exit - if (lastStartIndex < input.length) { - buffer.add(Normal(input.substring(lastStartIndex))) + if (openIndex != foundIndex) { + builder.appendText(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(">") + if (findHrefUrl.startsWith("https://matrix.to/#/@")) { + val userId = UserId(findHrefUrl.substringAfter("https://matrix.to/#/").substringBeforeLast("\"")) + builder.appendPerson(userId, "@${tagContent.removePrefix("@")}") + if (input.getOrNull(openIndex) == ':') { + openIndex++ + lastStartIndex = openIndex + } + } else { + builder.appendLink(findHrefUrl, label = tagContent) + } + } + + "b" -> builder.appendBold(tagContent) + "strong" -> builder.appendBold(tagContent) + "i" -> builder.appendItalic(tagContent) + "em" -> builder.appendItalic(tagContent) + + else -> builder.appendText(tagContent) + } } - break } - } - RichText(buffer) - }.onFailure { - it.printStackTrace() - println(input) - }.getOrThrow() - } + } else { + // check for urls + val urlIndex = input.indexOf("http", startIndex = openIndex) + if (urlIndex != -1) { + if (lastStartIndex != urlIndex) { + builder.appendText(input.substring(lastStartIndex, urlIndex)) + } + val originalUrl = input.substring(urlIndex) + val urlEndIndex = originalUrl.indexOfFirst { it == '\n' || it == ' ' } + val urlContinuesUntilEnd = urlEndIndex == -1 + when { + urlContinuesUntilEnd -> { + val cleanedUrl = originalUrl.bestGuessStripTrailingUrlChar() + builder.appendLink(url = cleanedUrl, label = null) + if (cleanedUrl != originalUrl) { + builder.appendText(originalUrl.last().toString()) + } + break + } + + else -> { + val originalUrl = input.substring(urlIndex, urlEndIndex) + val cleanedUrl = originalUrl.bestGuessStripTrailingUrlChar() + builder.appendLink(url = cleanedUrl, label = null) + openIndex = if (originalUrl == cleanedUrl) urlEndIndex else urlEndIndex - 1 + lastStartIndex = openIndex + continue + } + } + } + + // exit + if (lastStartIndex < input.length) { + builder.appendText(input.substring(lastStartIndex)) + } + break + } + } + return RichText(builder.build()) + } } private fun String.removeHtmlEntities() = this.replace(""", "\"").replace("'", "'") -private fun String.dropTextFallback() = this.lines().dropWhile { it.startsWith("> ") || it.isEmpty() }.joinToString("\n") +private fun String.dropTextFallback() = this.lines() + .dropWhile { it.startsWith("> ") || it.isEmpty() } + .joinToString(separator = "\n") private fun String.bestGuessStripTrailingUrlChar(): String { val last = this.last() @@ -150,4 +148,50 @@ private fun String.bestGuessStripTrailingUrlChar(): String { } else { this } +} + +private class PartBuilder { + + private var normalBuffer = StringBuilder() + + private val parts = mutableSetOf() + + fun appendText(value: String) { + normalBuffer.append(value.cleanFirstTextLine()) + } + + fun appendItalic(value: String) { + flushNormalBuffer() + parts.add(Italic(value.cleanFirstTextLine())) + } + + fun appendBold(value: String) { + flushNormalBuffer() + parts.add(Bold(value.cleanFirstTextLine())) + } + + private fun String.cleanFirstTextLine() = if (parts.isEmpty() && normalBuffer.isEmpty()) this.trimStart() else this + + fun appendPerson(userId: UserId, displayName: String) { + flushNormalBuffer() + parts.add(Person(userId, displayName)) + } + + fun appendLink(url: String, label: String?) { + flushNormalBuffer() + parts.add(Link(url, label ?: url)) + } + + fun build(): Set { + flushNormalBuffer() + return parts + } + + private fun flushNormalBuffer() { + if (normalBuffer.isNotEmpty()) { + parts.add(Normal(normalBuffer.toString())) + normalBuffer.clear() + } + } + } \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt index 22f122d..ac80b1f 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt @@ -20,7 +20,7 @@ class RichMessageParserTest { @Test fun `skips p tags`() = runParserTest( input = "Hello world!

foo bar

after paragraph", - expected = RichText(setOf(Normal("Hello world! "), Normal("foo bar"), Normal(" after paragraph"))) + expected = RichText(setOf(Normal("Hello world! foo bar after paragraph"))) ) @Test @@ -66,7 +66,7 @@ class RichMessageParserTest { @Test fun `replaces br tags`() = runParserTest( input = "Hello world!
next line
another line", - expected = RichText(setOf(Normal("Hello world!"), Normal("\n"), Normal("next line"), Normal("\n"), Normal("another line"))) + expected = RichText(setOf(Normal("Hello world!\nnext line\nanother line"))) ) @Test From 919c973d5d01a1174816ae4118641a89fdad13df Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 28 Oct 2022 19:14:44 +0100 Subject: [PATCH 11/28] submit release notifications to the main small-talk room --- tools/beta-release/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/beta-release/app.js b/tools/beta-release/app.js index 14a439a..9ee62da 100644 --- a/tools/beta-release/app.js +++ b/tools/beta-release/app.js @@ -7,7 +7,7 @@ const config = { rcBranchesFrom: "main", rcMergesTo: "release", packageName: "app.dapk.st", - matrixRoomId: "!jgNenzNPtSpJLjjsxe:matrix.org" + matrixRoomId: "!fuHEgUsoPRBQynkdkF:iswell.cool" } const rcBranchName = "release-candidate" @@ -175,4 +175,4 @@ const readVersionFile = async (github, branch) => { content: JSON.parse(content), sha: result.data.sha, } -} \ No newline at end of file +} From 3a4b6dfa32a3460920ffc531ac20b2442ed5dd3b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 28 Oct 2022 19:17:30 +0100 Subject: [PATCH 12/28] Mark github releases as non preview it's happening! --- tools/beta-release/release.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/beta-release/release.js b/tools/beta-release/release.js index 358147c..43f3a59 100644 --- a/tools/beta-release/release.js +++ b/tools/beta-release/release.js @@ -50,7 +50,7 @@ export const release = async (github, version, applicationId, artifacts, config) owner: config.owner, repo: config.repo, tag_name: version.name, - prerelease: true, + prerelease: false, generate_release_notes: true, }) @@ -219,4 +219,4 @@ const sendReleaseMessage = async (release, config) => { "msgtype": "m.text" } await client.sendEvent(config.matrixRoomId, "m.room.message", content, "") -} \ No newline at end of file +} From 8e36efe0c22dadc956af2ca88feb40c3ad88c9d4 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 28 Oct 2022 22:19:16 +0100 Subject: [PATCH 13/28] message parsing refactor --- .../app/dapk/st/matrix/sync/SyncService.kt | 2 +- .../sync/internal/DefaultSyncService.kt | 1 + .../sync/internal/room/RoomEventsDecrypter.kt | 2 +- .../sync/internal/sync/RichMessageParser.kt | 197 ------------------ .../sync/internal/sync/RoomEventCreator.kt | 1 + .../sync/internal/sync/RoomEventFactory.kt | 1 + .../sync/internal/sync/message/HtmlParser.kt | 118 +++++++++++ .../sync/internal/sync/message/ParserScope.kt | 13 ++ .../sync/internal/sync/message/PartBuilder.kt | 56 +++++ .../sync/message/RichMessageParser.kt | 50 +++++ .../sync/internal/sync/message/UrlParser.kt | 47 +++++ .../internal/room/RoomEventsDecrypterTest.kt | 2 +- .../internal/sync/RichMessageParserTest.kt | 3 +- .../internal/sync/RoomEventCreatorTest.kt | 1 + 14 files changed, 293 insertions(+), 201 deletions(-) delete mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/ParserScope.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/RichMessageParser.kt create mode 100644 matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/UrlParser.kt diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt index c6923a0..a5726df 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt @@ -8,7 +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 app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt index 907388c..3eb9136 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt @@ -13,6 +13,7 @@ import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter import app.dapk.st.matrix.sync.internal.room.SyncEventDecrypter import app.dapk.st.matrix.sync.internal.room.SyncSideEffects import app.dapk.st.matrix.sync.internal.sync.* +import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt index 0f88ba5..3e2c540 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypter.kt @@ -6,7 +6,7 @@ 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 app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser import kotlinx.serialization.json.Json internal class RoomEventsDecrypter( diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt deleted file mode 100644 index 692d280..0000000 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParser.kt +++ /dev/null @@ -1,197 +0,0 @@ -package app.dapk.st.matrix.sync.internal.sync - -import app.dapk.st.matrix.common.RichText -import app.dapk.st.matrix.common.RichText.Part.* -import app.dapk.st.matrix.common.UserId - -private const val INVALID_TRAILING_CHARS = ",.:;?" -private const val TAG_OPEN = '<' -private const val TAG_CLOSE = '>' - -class RichMessageParser { - - fun parse(source: String): RichText { - val input = source - .removeHtmlEntities() - .dropTextFallback() - val builder = PartBuilder() - var openIndex = 0 - var closeIndex = 0 - var lastStartIndex = 0 - while (openIndex != -1) { - val foundIndex = input.indexOf(TAG_OPEN, startIndex = openIndex) - if (foundIndex != -1) { - closeIndex = input.indexOf(TAG_CLOSE, startIndex = foundIndex) - if (closeIndex == -1) { - openIndex++ - } else { - val wholeTag = input.substring(foundIndex, closeIndex + 1) - val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' }) - - if (tagName.startsWith('@')) { - if (openIndex != foundIndex) { - builder.appendText(input.substring(openIndex, foundIndex)) - } - builder.appendPerson(UserId(tagName), tagName) - openIndex = foundIndex + wholeTag.length - lastStartIndex = openIndex - continue - } - - if (tagName == "br") { - if (openIndex != foundIndex) { - builder.appendText(input.substring(openIndex, foundIndex)) - } - builder.appendText("\n") - openIndex = foundIndex + wholeTag.length - lastStartIndex = openIndex - continue - } - - val exitTag = "" - val exitIndex = input.indexOf(exitTag, startIndex = closeIndex) - if (exitIndex == -1) { - openIndex++ - } else { - when (tagName) { - "mx-reply" -> { - openIndex = exitIndex + exitTag.length - lastStartIndex = openIndex - continue - } - } - - if (openIndex != foundIndex) { - builder.appendText(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(">") - if (findHrefUrl.startsWith("https://matrix.to/#/@")) { - val userId = UserId(findHrefUrl.substringAfter("https://matrix.to/#/").substringBeforeLast("\"")) - builder.appendPerson(userId, "@${tagContent.removePrefix("@")}") - if (input.getOrNull(openIndex) == ':') { - openIndex++ - lastStartIndex = openIndex - } - } else { - builder.appendLink(findHrefUrl, label = tagContent) - } - } - - "b" -> builder.appendBold(tagContent) - "strong" -> builder.appendBold(tagContent) - "i" -> builder.appendItalic(tagContent) - "em" -> builder.appendItalic(tagContent) - - else -> builder.appendText(tagContent) - } - } - } - } else { - // check for urls - val urlIndex = input.indexOf("http", startIndex = openIndex) - if (urlIndex != -1) { - if (lastStartIndex != urlIndex) { - builder.appendText(input.substring(lastStartIndex, urlIndex)) - } - - val originalUrl = input.substring(urlIndex) - val urlEndIndex = originalUrl.indexOfFirst { it == '\n' || it == ' ' } - val urlContinuesUntilEnd = urlEndIndex == -1 - when { - urlContinuesUntilEnd -> { - val cleanedUrl = originalUrl.bestGuessStripTrailingUrlChar() - builder.appendLink(url = cleanedUrl, label = null) - if (cleanedUrl != originalUrl) { - builder.appendText(originalUrl.last().toString()) - } - break - } - - else -> { - val originalUrl = input.substring(urlIndex, urlEndIndex) - val cleanedUrl = originalUrl.bestGuessStripTrailingUrlChar() - builder.appendLink(url = cleanedUrl, label = null) - openIndex = if (originalUrl == cleanedUrl) urlEndIndex else urlEndIndex - 1 - lastStartIndex = openIndex - continue - } - } - } - - // exit - if (lastStartIndex < input.length) { - builder.appendText(input.substring(lastStartIndex)) - } - break - } - } - return RichText(builder.build()) - } -} - -private fun String.removeHtmlEntities() = this.replace(""", "\"").replace("'", "'") - -private fun String.dropTextFallback() = this.lines() - .dropWhile { it.startsWith("> ") || it.isEmpty() } - .joinToString(separator = "\n") - -private fun String.bestGuessStripTrailingUrlChar(): String { - val last = this.last() - return if (INVALID_TRAILING_CHARS.contains(last)) { - this.dropLast(1) - } else { - this - } -} - -private class PartBuilder { - - private var normalBuffer = StringBuilder() - - private val parts = mutableSetOf() - - fun appendText(value: String) { - normalBuffer.append(value.cleanFirstTextLine()) - } - - fun appendItalic(value: String) { - flushNormalBuffer() - parts.add(Italic(value.cleanFirstTextLine())) - } - - fun appendBold(value: String) { - flushNormalBuffer() - parts.add(Bold(value.cleanFirstTextLine())) - } - - private fun String.cleanFirstTextLine() = if (parts.isEmpty() && normalBuffer.isEmpty()) this.trimStart() else this - - fun appendPerson(userId: UserId, displayName: String) { - flushNormalBuffer() - parts.add(Person(userId, displayName)) - } - - fun appendLink(url: String, label: String?) { - flushNormalBuffer() - parts.add(Link(url, label ?: url)) - } - - fun build(): Set { - flushNormalBuffer() - return parts - } - - private fun flushNormalBuffer() { - if (normalBuffer.isNotEmpty()) { - parts.add(Normal(normalBuffer.toString())) - normalBuffer.clear() - } - } - -} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt index 72d9a46..d291f6f 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreator.kt @@ -12,6 +12,7 @@ import app.dapk.st.matrix.sync.RoomMembersService import app.dapk.st.matrix.sync.find import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent +import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser private typealias Lookup = suspend (EventId) -> LookupResult diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt index c8b1f10..dbfc70c 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventFactory.kt @@ -6,6 +6,7 @@ import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomMembersService import app.dapk.st.matrix.sync.find import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent +import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser private val UNKNOWN_AUTHOR = RoomMember(id = UserId("unknown"), displayName = null, avatarUrl = null) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt new file mode 100644 index 0000000..d855bb2 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt @@ -0,0 +1,118 @@ +package app.dapk.st.matrix.sync.internal.sync.message + +import app.dapk.st.matrix.common.UserId + +private const val TAG_OPEN = '<' +private const val TAG_CLOSE = '>' +private const val NO_RESULT_FOUND = -1 + +internal class HtmlParser { + + fun parseHtmlTags(input: String, searchIndex: Int, builder: PartBuilder) = input.findTag( + fromIndex = searchIndex, + onInvalidTag = { builder.appendText(input[it].toString()) }, + onTag = { tagOpen, tagClose -> + val wholeTag = input.substring(tagOpen, tagClose + 1) + val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' }) + + when { + tagName.startsWith('@') -> { + appendTextBeforeTag(searchIndex, tagOpen, builder, input) + builder.appendPerson(UserId(tagName), tagName) + tagClose.next() + } + + tagName == "br" -> { + appendTextBeforeTag(searchIndex, tagOpen, builder, input) + builder.appendText("\n") + tagClose.next() + } + + else -> { + val exitTag = "" + val exitIndex = input.indexOf(exitTag, startIndex = tagClose) + val exitTagClose = exitIndex + exitTag.length + if (exitIndex == END_SEARCH) { + builder.appendText(input[searchIndex].toString()) + searchIndex.next() + } else { + when (tagName) { + "mx-reply" -> { + exitTagClose + } + + else -> { + appendTextBeforeTag(searchIndex, tagOpen, builder, input) + val tagContent = input.substring(tagClose + 1, exitIndex) + when (tagName) { + "a" -> { + val findHrefUrl = wholeTag.substringAfter("href=").replace("\"", "").removeSuffix(">") + if (findHrefUrl.startsWith("https://matrix.to/#/@")) { + val userId = UserId(findHrefUrl.substringAfter("https://matrix.to/#/").substringBeforeLast("\"")) + builder.appendPerson(userId, "@${tagContent.removePrefix("@")}") + if (input.getOrNull(exitTagClose) == ':') { + exitTagClose.next() + } else { + exitTagClose + } + } else { + builder.appendLink(findHrefUrl, label = tagContent) + exitTagClose + } + } + + "b" -> { + builder.appendBold(tagContent) + exitTagClose + } + + "strong" -> { + builder.appendBold(tagContent) + exitTagClose + } + + "i" -> { + builder.appendItalic(tagContent) + exitTagClose + } + + "em" -> { + builder.appendItalic(tagContent) + exitTagClose + } + + else -> { + builder.appendText(tagContent) + exitTagClose + } + } + } + } + } + } + } + } + ) + + private fun appendTextBeforeTag(searchIndex: Int, tagOpen: Int, builder: PartBuilder, input: String) { + if (searchIndex != tagOpen) { + builder.appendText(input.substring(searchIndex, tagOpen)) + } + } + + private fun String.findTag(fromIndex: Int, onInvalidTag: (Int) -> Unit, onTag: (Int, Int) -> Int): Int { + return when (val foundIndex = this.indexOf(TAG_OPEN, startIndex = fromIndex)) { + NO_RESULT_FOUND -> END_SEARCH + + else -> when (val closeIndex = indexOf(TAG_CLOSE, startIndex = foundIndex)) { + NO_RESULT_FOUND -> { + onInvalidTag(fromIndex) + fromIndex + 1 + } + + else -> onTag(foundIndex, closeIndex) + } + } + } + +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/ParserScope.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/ParserScope.kt new file mode 100644 index 0000000..8674f47 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/ParserScope.kt @@ -0,0 +1,13 @@ +package app.dapk.st.matrix.sync.internal.sync.message + +internal typealias SearchIndex = Int + +internal fun Int.next() = this + 1 + + +internal interface ParserScope { + fun appendTextBeforeTag(searchIndex: Int, tagOpen: Int, builder: PartBuilder, input: String) + + fun SearchIndex.next(): SearchIndex + +} \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt new file mode 100644 index 0000000..db3c93b --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt @@ -0,0 +1,56 @@ +package app.dapk.st.matrix.sync.internal.sync.message + +import app.dapk.st.matrix.common.RichText +import app.dapk.st.matrix.common.UserId + +internal class PartBuilder { + + private var normalBuffer = StringBuilder() + + private val parts = mutableSetOf() + + fun appendText(value: String) { + normalBuffer.append(value.cleanFirstTextLine()) + } + + fun appendItalic(value: String) { + flushNormalBuffer() + parts.add(RichText.Part.Italic(value.cleanFirstTextLine())) + } + + fun appendBold(value: String) { + flushNormalBuffer() + parts.add(RichText.Part.Bold(value.cleanFirstTextLine())) + } + + private fun String.cleanFirstTextLine() = if (parts.isEmpty() && normalBuffer.isEmpty()) this.trimStart() else this + + fun appendPerson(userId: UserId, displayName: String) { + flushNormalBuffer() + parts.add(RichText.Part.Person(userId, displayName)) + } + + fun appendLink(url: String, label: String?) { + flushNormalBuffer() + parts.add(RichText.Part.Link(url, label ?: url)) + } + + fun build(): Set { + flushNormalBuffer() + return parts + } + + private fun flushNormalBuffer() { + if (normalBuffer.isNotEmpty()) { + parts.add(RichText.Part.Normal(normalBuffer.toString())) + normalBuffer.clear() + } + } + +} + +internal fun PartBuilder.appendTextBeforeTag(previousIndex: Int, tagOpenIndex: Int, input: String) { + if (previousIndex != tagOpenIndex) { + this.appendText(input.substring(previousIndex, tagOpenIndex)) + } +} diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/RichMessageParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/RichMessageParser.kt new file mode 100644 index 0000000..2d68b78 --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/RichMessageParser.kt @@ -0,0 +1,50 @@ +package app.dapk.st.matrix.sync.internal.sync.message + +import app.dapk.st.matrix.common.RichText +import kotlin.math.max + +internal const val END_SEARCH = -1 + +class RichMessageParser { + + private val htmlParser = HtmlParser() + private val urlParser = UrlParser() + + fun parse(source: String): RichText { + val input = source + .removeHtmlEntities() + .dropTextFallback() + val builder = PartBuilder() + var nextIndex = 0 + while (nextIndex != END_SEARCH) { + val htmlResult = htmlParser.parseHtmlTags(input, nextIndex, builder) + val linkStartIndex = findUrlStartIndex(htmlResult, nextIndex) + val urlResult = urlParser.parseUrl(input, linkStartIndex, builder) + + val hasReachedEnd = hasReachedEnd(htmlResult, urlResult, input) + if (hasReachedEnd && hasUnprocessedText(htmlResult, urlResult, input)) { + builder.appendText(input.substring(nextIndex)) + } + nextIndex = if (hasReachedEnd) END_SEARCH else max(htmlResult, urlResult) + } + return RichText(builder.build()) + } + + private fun hasUnprocessedText(htmlResult: Int, urlResult: Int, input: String) = htmlResult < input.length && urlResult < input.length + + private fun findUrlStartIndex(htmlResult: Int, searchIndex: Int) = when { + htmlResult == END_SEARCH && searchIndex == 0 -> 0 + htmlResult == END_SEARCH -> searchIndex + else -> htmlResult + } + + private fun hasReachedEnd(htmlResult: SearchIndex, urlResult: Int, input: String) = + (htmlResult == END_SEARCH && urlResult == END_SEARCH) || (htmlResult >= input.length || urlResult >= input.length) + +} + +private fun String.removeHtmlEntities() = this.replace(""", "\"").replace("'", "'") + +private fun String.dropTextFallback() = this.lines() + .dropWhile { it.startsWith("> ") || it.isEmpty() } + .joinToString(separator = "\n") diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/UrlParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/UrlParser.kt new file mode 100644 index 0000000..a37869e --- /dev/null +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/UrlParser.kt @@ -0,0 +1,47 @@ +package app.dapk.st.matrix.sync.internal.sync.message + +private const val INVALID_TRAILING_CHARS = ",.:;?" + +internal class UrlParser { + + fun parseUrl(input: String, linkStartIndex: Int, builder: PartBuilder): Int { + val urlIndex = input.indexOf("http", startIndex = linkStartIndex) + val urlResult = if (urlIndex == END_SEARCH) END_SEARCH else { + builder.appendTextBeforeTag(linkStartIndex, urlIndex, input) + + val originalUrl = input.substring(urlIndex) + val urlEndIndex = originalUrl.indexOfFirst { it == '\n' || it == ' ' } + val urlContinuesUntilEnd = urlEndIndex == -1 + + when { + urlContinuesUntilEnd -> { + val cleanedUrl = originalUrl.bestGuessStripTrailingUrlChar() + builder.appendLink(url = cleanedUrl, label = null) + if (cleanedUrl != originalUrl) { + builder.appendText(originalUrl.last().toString()) + } + input.length.next() + } + + else -> { + val originalUrl = input.substring(urlIndex, urlEndIndex) + val cleanedUrl = originalUrl.bestGuessStripTrailingUrlChar() + builder.appendLink(url = cleanedUrl, label = null) + if (originalUrl == cleanedUrl) urlEndIndex else urlEndIndex - 1 + } + } + } + return urlResult + } + +} + + +private fun String.bestGuessStripTrailingUrlChar(): String { + val last = this.last() + return if (INVALID_TRAILING_CHARS.contains(last)) { + this.dropLast(1) + } else { + this + } +} diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt index 3aca409..dea7f06 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/room/RoomEventsDecrypterTest.kt @@ -5,7 +5,7 @@ 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 app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser import fake.FakeMatrixLogger import fake.FakeMessageDecrypter import fixture.* diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt index ac80b1f..3cf80f8 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt @@ -2,6 +2,7 @@ package app.dapk.st.matrix.sync.internal.sync import app.dapk.st.matrix.common.RichText import app.dapk.st.matrix.common.RichText.Part.* +import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser import fixture.aUserId import org.amshove.kluent.shouldBeEqualTo import org.junit.Ignore @@ -157,7 +158,7 @@ class RichMessageParserTest { expected = RichText( setOf( Normal("hello "), - RichText.Part.BoldItalic("wor"), + BoldItalic("wor"), Normal("ld"), ) ) diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt index a2d5cc7..6f11704 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RoomEventCreatorTest.kt @@ -6,6 +6,7 @@ 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 +import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser import fake.FakeErrorTracker import fake.FakeRoomMembersService import fixture.* From 55745b9c41e0c0aa736754a6f1fb2169d1ac835d Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 28 Oct 2022 23:25:30 +0100 Subject: [PATCH 14/28] more robust url parsing --- .../sync/internal/sync/message/HtmlParser.kt | 112 +++++++++++------- .../sync/internal/sync/message/PartBuilder.kt | 5 + .../sync/message/RichMessageParser.kt | 24 +++- .../sync/internal/sync/message/UrlParser.kt | 20 +++- .../internal/sync/RichMessageParserTest.kt | 10 +- .../internal/sync/RoomEventCreatorTest.kt | 2 +- 6 files changed, 114 insertions(+), 59 deletions(-) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt index d855bb2..f9e2e0f 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt @@ -24,68 +24,27 @@ internal class HtmlParser { tagName == "br" -> { appendTextBeforeTag(searchIndex, tagOpen, builder, input) - builder.appendText("\n") + builder.appendNewline() tagClose.next() } else -> { val exitTag = "" val exitIndex = input.indexOf(exitTag, startIndex = tagClose) - val exitTagClose = exitIndex + exitTag.length + val exitTagCloseIndex = exitIndex + exitTag.length if (exitIndex == END_SEARCH) { builder.appendText(input[searchIndex].toString()) searchIndex.next() } else { when (tagName) { "mx-reply" -> { - exitTagClose + exitTagCloseIndex } else -> { appendTextBeforeTag(searchIndex, tagOpen, builder, input) val tagContent = input.substring(tagClose + 1, exitIndex) - when (tagName) { - "a" -> { - val findHrefUrl = wholeTag.substringAfter("href=").replace("\"", "").removeSuffix(">") - if (findHrefUrl.startsWith("https://matrix.to/#/@")) { - val userId = UserId(findHrefUrl.substringAfter("https://matrix.to/#/").substringBeforeLast("\"")) - builder.appendPerson(userId, "@${tagContent.removePrefix("@")}") - if (input.getOrNull(exitTagClose) == ':') { - exitTagClose.next() - } else { - exitTagClose - } - } else { - builder.appendLink(findHrefUrl, label = tagContent) - exitTagClose - } - } - - "b" -> { - builder.appendBold(tagContent) - exitTagClose - } - - "strong" -> { - builder.appendBold(tagContent) - exitTagClose - } - - "i" -> { - builder.appendItalic(tagContent) - exitTagClose - } - - "em" -> { - builder.appendItalic(tagContent) - exitTagClose - } - - else -> { - builder.appendText(tagContent) - exitTagClose - } - } + handleTagWithContent(input, tagName, wholeTag, builder, tagContent, exitTagCloseIndex) } } } @@ -94,6 +53,65 @@ internal class HtmlParser { } ) + private fun handleTagWithContent( + input: String, + tagName: String, + wholeTag: String, + builder: PartBuilder, + tagContent: String, + exitTagCloseIndex: Int + ) = when (tagName) { + "a" -> { + val findHrefUrl = wholeTag.substringAfter("href=").replace("\"", "").removeSuffix(">") + if (findHrefUrl.startsWith("https://matrix.to/#/@")) { + val userId = UserId(findHrefUrl.substringAfter("https://matrix.to/#/").substringBeforeLast("\"")) + builder.appendPerson(userId, "@${tagContent.removePrefix("@")}") + ignoreMatrixColonMentionSuffix(input, exitTagCloseIndex) + } else { + builder.appendLink(findHrefUrl, label = tagContent) + exitTagCloseIndex + } + } + + "b" -> { + builder.appendBold(tagContent) + exitTagCloseIndex + } + + "p" -> { + builder.appendText(tagContent) + builder.appendNewline() + builder.appendNewline() + exitTagCloseIndex + } + + "strong" -> { + builder.appendBold(tagContent) + exitTagCloseIndex + } + + "i" -> { + builder.appendItalic(tagContent) + exitTagCloseIndex + } + + "em" -> { + builder.appendItalic(tagContent) + exitTagCloseIndex + } + + else -> { + builder.appendText(tagContent) + exitTagCloseIndex + } + } + + private fun ignoreMatrixColonMentionSuffix(input: String, exitTagCloseIndex: Int) = if (input.getOrNull(exitTagCloseIndex) == ':') { + exitTagCloseIndex.next() + } else { + exitTagCloseIndex + } + private fun appendTextBeforeTag(searchIndex: Int, tagOpen: Int, builder: PartBuilder, input: String) { if (searchIndex != tagOpen) { builder.appendText(input.substring(searchIndex, tagOpen)) @@ -115,4 +133,8 @@ internal class HtmlParser { } } + fun test(startingFrom: Int, intput: String): Int { + return intput.indexOf('<', startingFrom) + } + } \ No newline at end of file diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt index db3c93b..ab34699 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt @@ -54,3 +54,8 @@ internal fun PartBuilder.appendTextBeforeTag(previousIndex: Int, tagOpenIndex: I this.appendText(input.substring(previousIndex, tagOpenIndex)) } } + +internal fun PartBuilder.appendNewline() { + this.appendText("\n") +} + diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/RichMessageParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/RichMessageParser.kt index 2d68b78..ac537da 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/RichMessageParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/RichMessageParser.kt @@ -17,15 +17,27 @@ class RichMessageParser { val builder = PartBuilder() var nextIndex = 0 while (nextIndex != END_SEARCH) { - val htmlResult = htmlParser.parseHtmlTags(input, nextIndex, builder) - val linkStartIndex = findUrlStartIndex(htmlResult, nextIndex) - val urlResult = urlParser.parseUrl(input, linkStartIndex, builder) + val htmlStart = htmlParser.test(nextIndex, input) + val urlStart = urlParser.test(nextIndex, input) - val hasReachedEnd = hasReachedEnd(htmlResult, urlResult, input) - if (hasReachedEnd && hasUnprocessedText(htmlResult, urlResult, input)) { + val firstResult = if (htmlStart < urlStart) { + htmlParser.parseHtmlTags(input, nextIndex, builder) + } else { + urlParser.parseUrl(input, nextIndex, builder) + } + + val secondStartIndex = findUrlStartIndex(firstResult, nextIndex) + val secondResult = if (htmlStart < urlStart) { + urlParser.parseUrl(input, secondStartIndex, builder) + } else { + htmlParser.parseHtmlTags(input, secondStartIndex, builder) + } + + val hasReachedEnd = hasReachedEnd(firstResult, secondResult, input) + if (hasReachedEnd && hasUnprocessedText(firstResult, secondResult, input)) { builder.appendText(input.substring(nextIndex)) } - nextIndex = if (hasReachedEnd) END_SEARCH else max(htmlResult, urlResult) + nextIndex = if (hasReachedEnd) END_SEARCH else max(firstResult, secondResult) } return RichText(builder.build()) } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/UrlParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/UrlParser.kt index a37869e..9366120 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/UrlParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/UrlParser.kt @@ -1,16 +1,25 @@ package app.dapk.st.matrix.sync.internal.sync.message -private const val INVALID_TRAILING_CHARS = ",.:;?" +private const val INVALID_TRAILING_CHARS = ",.:;?<>" internal class UrlParser { + private fun String.hasLookAhead(current: Int, value: String): Boolean { + return length > current + value.length && this.substring(current, current + value.length) == value + } + fun parseUrl(input: String, linkStartIndex: Int, builder: PartBuilder): Int { val urlIndex = input.indexOf("http", startIndex = linkStartIndex) - val urlResult = if (urlIndex == END_SEARCH) END_SEARCH else { + return if (urlIndex == END_SEARCH) END_SEARCH else { builder.appendTextBeforeTag(linkStartIndex, urlIndex, input) val originalUrl = input.substring(urlIndex) - val urlEndIndex = originalUrl.indexOfFirst { it == '\n' || it == ' ' } + var index = 0 + val maybeUrl = originalUrl.takeWhile { + it != '\n' && it != ' ' && !originalUrl.hasLookAhead(index++, " Date: Sat, 29 Oct 2022 10:33:56 +0100 Subject: [PATCH 15/28] handle headers as bold text --- .../sync/internal/sync/message/HtmlParser.kt | 19 +++++++------------ .../sync/internal/sync/message/PartBuilder.kt | 8 ++++++++ .../internal/sync/RichMessageParserTest.kt | 14 +++++++++----- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt index f9e2e0f..c7b1ece 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt @@ -73,7 +73,7 @@ internal class HtmlParser { } } - "b" -> { + "b", "strong" -> { builder.appendBold(tagContent) exitTagCloseIndex } @@ -81,21 +81,16 @@ internal class HtmlParser { "p" -> { builder.appendText(tagContent) builder.appendNewline() + exitTagCloseIndex + } + + "h1", "h2", "h3", "h4", "h5" -> { + builder.appendBold(tagContent) builder.appendNewline() exitTagCloseIndex } - "strong" -> { - builder.appendBold(tagContent) - exitTagCloseIndex - } - - "i" -> { - builder.appendItalic(tagContent) - exitTagCloseIndex - } - - "em" -> { + "i", "em" -> { builder.appendItalic(tagContent) exitTagCloseIndex } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt index ab34699..3fd60bb 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt @@ -37,6 +37,14 @@ internal class PartBuilder { fun build(): Set { flushNormalBuffer() + val last = parts.last() + if (last is RichText.Part.Normal) { + parts.remove(last) + val newContent = last.content.trimEnd() + if (newContent.isNotEmpty()) { + parts.add(last.copy(content = newContent)) + } + } return parts } diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt index 7f8f01e..13e8649 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt @@ -21,7 +21,7 @@ class RichMessageParserTest { @Test fun `parses p tags`() = runParserTest( input = "

Hello world!

foo bar

after paragraph", - expected = RichText(setOf(Normal("Hello world!\n\nfoo bar\n\nafter paragraph"))) + expected = RichText(setOf(Normal("Hello world!\nfoo bar\nafter paragraph"))) ) @Test @@ -49,18 +49,22 @@ class RichMessageParserTest { ) @Test - fun `skips header tags`() = runParserTest( + fun `parses header tags`() = runParserTest( Case( input = "

hello

", - expected = RichText(setOf(Normal("hello"))) + expected = RichText(setOf(Bold("hello"))) + ), + Case( + input = "

hello

text after title", + expected = RichText(setOf(Bold("hello"), Normal("\ntext after title"))) ), Case( input = "

hello

", - expected = RichText(setOf(Normal("hello"))) + expected = RichText(setOf(Bold("hello"))) ), Case( input = "

hello

", - expected = RichText(setOf(Normal("hello"))) + expected = RichText(setOf(Bold("hello"))) ), ) From 446d7299814b84a94587059846aa7bad9a8feaf0 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 29 Oct 2022 11:23:44 +0100 Subject: [PATCH 16/28] adding basic list support --- .../sync/internal/sync/message/HtmlParser.kt | 69 ++++++++++++++++++- .../internal/sync/RichMessageParserTest.kt | 21 ++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt index c7b1ece..b979f66 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt @@ -5,10 +5,11 @@ import app.dapk.st.matrix.common.UserId private const val TAG_OPEN = '<' private const val TAG_CLOSE = '>' private const val NO_RESULT_FOUND = -1 +private const val LI_VALUE_CAPTURE = "value=\"" internal class HtmlParser { - fun parseHtmlTags(input: String, searchIndex: Int, builder: PartBuilder) = input.findTag( + fun parseHtmlTags(input: String, searchIndex: Int, builder: PartBuilder): SearchIndex = input.findTag( fromIndex = searchIndex, onInvalidTag = { builder.appendText(input[it].toString()) }, onTag = { tagOpen, tagClose -> @@ -59,7 +60,7 @@ internal class HtmlParser { wholeTag: String, builder: PartBuilder, tagContent: String, - exitTagCloseIndex: Int + exitTagCloseIndex: Int, ) = when (tagName) { "a" -> { val findHrefUrl = wholeTag.substringAfter("href=").replace("\"", "").removeSuffix(">") @@ -84,6 +85,11 @@ internal class HtmlParser { exitTagCloseIndex } + "ul", "ol" -> { + parseList(tagName, tagContent, builder) + exitTagCloseIndex + } + "h1", "h2", "h3", "h4", "h5" -> { builder.appendBold(tagContent) builder.appendNewline() @@ -128,8 +134,65 @@ internal class HtmlParser { } } + private fun parseList(parentTag: String, parentContent: String, builder: PartBuilder) { + var nextIndex = 0 + var index = 1 + while (nextIndex != END_SEARCH) { + nextIndex = singleTagParser(parentContent, "li", nextIndex, builder) { wholeTag, tagContent -> + val content = when (parentTag) { + "ol" -> { + index = wholeTag.indexOf(LI_VALUE_CAPTURE).let { + if (it == -1) { + index + } else { + val start = it + LI_VALUE_CAPTURE.length + wholeTag.substring(start).substringBefore('\"').toInt() + } + } + + "$index. $tagContent".also { + index++ + } + } + + else -> "- $tagContent" + } + builder.appendText(content) + builder.appendNewline() + } + } + } + + + private fun singleTagParser(content: String, wantedTagName: String, searchIndex: Int, builder: PartBuilder, onTag: (String, String) -> Unit): SearchIndex { + return content.findTag( + fromIndex = searchIndex, + onInvalidTag = { builder.appendText(content[it].toString()) }, + onTag = { tagOpen, tagClose -> + val wholeTag = content.substring(tagOpen, tagClose + 1) + val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' }) + + if (tagName == wantedTagName) { + val exitTag = "" + val exitIndex = content.indexOf(exitTag, startIndex = tagClose) + val exitTagCloseIndex = exitIndex + exitTag.length + if (exitIndex == END_SEARCH) { + builder.appendText(content[searchIndex].toString()) + searchIndex.next() + } else { + val tagContent = content.substring(tagClose + 1, exitIndex) + onTag(wholeTag, tagContent) + exitTagCloseIndex + } + } else { + END_SEARCH + } + } + ) + } + fun test(startingFrom: Int, intput: String): Int { - return intput.indexOf('<', startingFrom) + return intput.indexOf(TAG_OPEN, startingFrom) } } \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt index 13e8649..5be1d1b 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt @@ -74,6 +74,27 @@ class RichMessageParserTest { expected = RichText(setOf(Normal("Hello world!\nnext line\nanother line"))) ) + + @Test + fun `parses lists`() = runParserTest( + Case( + input = "
  • content in list item
  • another item in list
", + expected = RichText(setOf(Normal("- content in list item\n- another item in list"))) + ), + Case( + input = "
  1. content in list item
  2. another item in list
", + expected = RichText(setOf(Normal("1. content in list item\n2. another item in list"))) + ), + Case( + input = """
  1. content in list item
  2. another item in list
""", + expected = RichText(setOf(Normal("5. content in list item\n6. another item in list"))) + ), + Case( + input = """
  1. content in list item
  2. another item in list
  3. another change
  4. without value
""", + expected = RichText(setOf(Normal("3. content in list item\n4. another item in list\n10. another change\n11. without value"))) + ), + ) + @Test fun `parses urls`() = runParserTest( Case( From 23b004ff02e44c68a9341930d14d2f165773e7c7 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 29 Oct 2022 12:24:32 +0100 Subject: [PATCH 17/28] supporting nested tags within a paragraph --- .../sync/internal/sync/message/HtmlParser.kt | 32 +++++++++++++++---- .../internal/sync/RichMessageParserTest.kt | 6 ++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt index b979f66..3190147 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt @@ -9,7 +9,7 @@ private const val LI_VALUE_CAPTURE = "value=\"" internal class HtmlParser { - fun parseHtmlTags(input: String, searchIndex: Int, builder: PartBuilder): SearchIndex = input.findTag( + fun parseHtmlTags(input: String, searchIndex: Int, builder: PartBuilder, nestingLevel: Int = 0): SearchIndex = input.findTag( fromIndex = searchIndex, onInvalidTag = { builder.appendText(input[it].toString()) }, onTag = { tagOpen, tagClose -> @@ -45,7 +45,7 @@ internal class HtmlParser { else -> { appendTextBeforeTag(searchIndex, tagOpen, builder, input) val tagContent = input.substring(tagClose + 1, exitIndex) - handleTagWithContent(input, tagName, wholeTag, builder, tagContent, exitTagCloseIndex) + handleTagWithContent(input, tagName, wholeTag, builder, tagContent, exitTagCloseIndex, nestingLevel) } } } @@ -61,6 +61,7 @@ internal class HtmlParser { builder: PartBuilder, tagContent: String, exitTagCloseIndex: Int, + nestingLevel: Int, ) = when (tagName) { "a" -> { val findHrefUrl = wholeTag.substringAfter("href=").replace("\"", "").removeSuffix(">") @@ -80,7 +81,18 @@ internal class HtmlParser { } "p" -> { - builder.appendText(tagContent) + if (tagContent.isNotEmpty() && nestingLevel < 2) { + var lastIndex = 0 + iterateIndex(start = 0) { searchIndex -> + lastIndex = searchIndex + parseHtmlTags(tagContent, searchIndex, builder, nestingLevel = nestingLevel + 1) + } + + if (lastIndex < tagContent.length) { + builder.appendText(tagContent.substring(lastIndex)) + } + } + builder.appendNewline() exitTagCloseIndex } @@ -135,10 +147,9 @@ internal class HtmlParser { } private fun parseList(parentTag: String, parentContent: String, builder: PartBuilder) { - var nextIndex = 0 var index = 1 - while (nextIndex != END_SEARCH) { - nextIndex = singleTagParser(parentContent, "li", nextIndex, builder) { wholeTag, tagContent -> + iterateIndex(start = 0) { nextIndex -> + singleTagParser(parentContent, "li", nextIndex, builder) { wholeTag, tagContent -> val content = when (parentTag) { "ol" -> { index = wholeTag.indexOf(LI_VALUE_CAPTURE).let { @@ -163,7 +174,6 @@ internal class HtmlParser { } } - private fun singleTagParser(content: String, wantedTagName: String, searchIndex: Int, builder: PartBuilder, onTag: (String, String) -> Unit): SearchIndex { return content.findTag( fromIndex = searchIndex, @@ -195,4 +205,12 @@ internal class HtmlParser { return intput.indexOf(TAG_OPEN, startingFrom) } + private fun iterateIndex(start: SearchIndex, action: (SearchIndex) -> SearchIndex): SearchIndex { + var nextIndex = start + while (nextIndex != END_SEARCH) { + nextIndex = action(nextIndex) + } + return nextIndex + } + } \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt index 5be1d1b..0b19262 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt @@ -24,6 +24,12 @@ class RichMessageParserTest { expected = RichText(setOf(Normal("Hello world!\nfoo bar\nafter paragraph"))) ) + @Test + fun `parses nesting within p tags`() = runParserTest( + input = "

Hello world!

", + expected = RichText(setOf(Bold("Hello world!"))) + ) + @Test fun `replaces quote entity`() = runParserTest( input = "Hello world! "foo bar"", From 2fab30060fdc66fa110bb80265c08f1607d22a8a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 29 Oct 2022 12:51:56 +0100 Subject: [PATCH 18/28] reduce duplication --- .../sync/internal/sync/message/HtmlParser.kt | 131 ++++++++++-------- .../sync/message/RichMessageParser.kt | 19 ++- .../internal/sync/RichMessageParserTest.kt | 2 +- 3 files changed, 88 insertions(+), 64 deletions(-) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt index 3190147..4c3f7b3 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt @@ -5,16 +5,19 @@ import app.dapk.st.matrix.common.UserId private const val TAG_OPEN = '<' private const val TAG_CLOSE = '>' private const val NO_RESULT_FOUND = -1 -private const val LI_VALUE_CAPTURE = "value=\"" +private val SKIPPED_TAGS = setOf("mx-reply") internal class HtmlParser { + fun test(startingFrom: Int, input: String): Int { + return input.indexOf(TAG_OPEN, startingFrom) + } + fun parseHtmlTags(input: String, searchIndex: Int, builder: PartBuilder, nestingLevel: Int = 0): SearchIndex = input.findTag( fromIndex = searchIndex, onInvalidTag = { builder.appendText(input[it].toString()) }, onTag = { tagOpen, tagClose -> - val wholeTag = input.substring(tagOpen, tagClose + 1) - val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' }) + val (wholeTag, tagName) = parseTag(input, tagOpen, tagClose) when { tagName.startsWith('@') -> { @@ -29,31 +32,40 @@ internal class HtmlParser { tagClose.next() } - else -> { - val exitTag = "" - val exitIndex = input.indexOf(exitTag, startIndex = tagClose) - val exitTagCloseIndex = exitIndex + exitTag.length - if (exitIndex == END_SEARCH) { - builder.appendText(input[searchIndex].toString()) - searchIndex.next() - } else { - when (tagName) { - "mx-reply" -> { - exitTagCloseIndex - } - - else -> { - appendTextBeforeTag(searchIndex, tagOpen, builder, input) - val tagContent = input.substring(tagClose + 1, exitIndex) - handleTagWithContent(input, tagName, wholeTag, builder, tagContent, exitTagCloseIndex, nestingLevel) - } - } - } - } + else -> parseTagWithContent(tagName, input, tagClose, builder, searchIndex, tagOpen, wholeTag, nestingLevel) } } ) + private fun parseTagWithContent( + tagName: String, + input: String, + tagClose: Int, + builder: PartBuilder, + searchIndex: Int, + tagOpen: Int, + wholeTag: String, + nestingLevel: Int + ): Int { + val exitTag = "" + val exitIndex = input.indexOf(exitTag, startIndex = tagClose) + val exitTagCloseIndex = exitIndex + exitTag.length + return when { + exitIndex == NO_RESULT_FOUND -> { + builder.appendText(input[searchIndex].toString()) + searchIndex.next() + } + + SKIPPED_TAGS.contains(tagName) -> exitTagCloseIndex + + else -> { + appendTextBeforeTag(searchIndex, tagOpen, builder, input) + val tagContent = input.substring(tagClose + 1, exitIndex) + handleTagWithContent(input, tagName, wholeTag, builder, tagContent, exitTagCloseIndex, nestingLevel) + } + } + } + private fun handleTagWithContent( input: String, tagName: String, @@ -64,14 +76,23 @@ internal class HtmlParser { nestingLevel: Int, ) = when (tagName) { "a" -> { - val findHrefUrl = wholeTag.substringAfter("href=").replace("\"", "").removeSuffix(">") - if (findHrefUrl.startsWith("https://matrix.to/#/@")) { - val userId = UserId(findHrefUrl.substringAfter("https://matrix.to/#/").substringBeforeLast("\"")) - builder.appendPerson(userId, "@${tagContent.removePrefix("@")}") - ignoreMatrixColonMentionSuffix(input, exitTagCloseIndex) - } else { - builder.appendLink(findHrefUrl, label = tagContent) - exitTagCloseIndex + val findHrefUrl = wholeTag.findTagAttribute("href") + when { + findHrefUrl == null -> { + builder.appendText(tagContent) + exitTagCloseIndex + } + + findHrefUrl.startsWith("https://matrix.to/#/@") -> { + val userId = UserId(findHrefUrl.substringAfter("https://matrix.to/#/").substringBeforeLast("\"")) + builder.appendPerson(userId, "@${tagContent.removePrefix("@")}") + ignoreMatrixColonMentionSuffix(input, exitTagCloseIndex) + } + + else -> { + builder.appendLink(findHrefUrl, label = tagContent) + exitTagCloseIndex + } } } @@ -83,7 +104,7 @@ internal class HtmlParser { "p" -> { if (tagContent.isNotEmpty() && nestingLevel < 2) { var lastIndex = 0 - iterateIndex(start = 0) { searchIndex -> + iterateSearchIndex { searchIndex -> lastIndex = searchIndex parseHtmlTags(tagContent, searchIndex, builder, nestingLevel = nestingLevel + 1) } @@ -147,23 +168,13 @@ internal class HtmlParser { } private fun parseList(parentTag: String, parentContent: String, builder: PartBuilder) { - var index = 1 - iterateIndex(start = 0) { nextIndex -> + var listIndex = 1 + iterateSearchIndex { nextIndex -> singleTagParser(parentContent, "li", nextIndex, builder) { wholeTag, tagContent -> val content = when (parentTag) { "ol" -> { - index = wholeTag.indexOf(LI_VALUE_CAPTURE).let { - if (it == -1) { - index - } else { - val start = it + LI_VALUE_CAPTURE.length - wholeTag.substring(start).substringBefore('\"').toInt() - } - } - - "$index. $tagContent".also { - index++ - } + listIndex = wholeTag.findTagAttribute("value")?.toInt() ?: listIndex + "$listIndex. $tagContent".also { listIndex++ } } else -> "- $tagContent" @@ -179,8 +190,7 @@ internal class HtmlParser { fromIndex = searchIndex, onInvalidTag = { builder.appendText(content[it].toString()) }, onTag = { tagOpen, tagClose -> - val wholeTag = content.substring(tagOpen, tagClose + 1) - val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' }) + val (wholeTag, tagName) = parseTag(content, tagOpen, tagClose) if (tagName == wantedTagName) { val exitTag = "" @@ -201,16 +211,21 @@ internal class HtmlParser { ) } - fun test(startingFrom: Int, intput: String): Int { - return intput.indexOf(TAG_OPEN, startingFrom) + private fun parseTag(input: String, tagOpen: Int, tagClose: Int): Pair { + val wholeTag = input.substring(tagOpen, tagClose + 1) + val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' }) + return wholeTag to tagName } +} - private fun iterateIndex(start: SearchIndex, action: (SearchIndex) -> SearchIndex): SearchIndex { - var nextIndex = start - while (nextIndex != END_SEARCH) { - nextIndex = action(nextIndex) +private fun String.findTagAttribute(name: String): String? { + val attribute = "$name=" + return this.indexOf(attribute).let { + if (it == NO_RESULT_FOUND) { + null + } else { + val start = it + attribute.length + this.substring(start).substringAfter('\"').substringBefore('\"') } - return nextIndex } - -} \ No newline at end of file +} diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/RichMessageParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/RichMessageParser.kt index ac537da..cb26b5b 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/RichMessageParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/RichMessageParser.kt @@ -14,9 +14,11 @@ class RichMessageParser { val input = source .removeHtmlEntities() .dropTextFallback() - val builder = PartBuilder() - var nextIndex = 0 - while (nextIndex != END_SEARCH) { + return RichText(collectRichText(input).build()) + } + + private fun collectRichText(input: String) = PartBuilder().also { builder -> + iterateSearchIndex { nextIndex -> val htmlStart = htmlParser.test(nextIndex, input) val urlStart = urlParser.test(nextIndex, input) @@ -37,9 +39,8 @@ class RichMessageParser { if (hasReachedEnd && hasUnprocessedText(firstResult, secondResult, input)) { builder.appendText(input.substring(nextIndex)) } - nextIndex = if (hasReachedEnd) END_SEARCH else max(firstResult, secondResult) + if (hasReachedEnd) END_SEARCH else max(firstResult, secondResult) } - return RichText(builder.build()) } private fun hasUnprocessedText(htmlResult: Int, urlResult: Int, input: String) = htmlResult < input.length && urlResult < input.length @@ -60,3 +61,11 @@ private fun String.removeHtmlEntities() = this.replace(""", "\"").replace(" private fun String.dropTextFallback() = this.lines() .dropWhile { it.startsWith("> ") || it.isEmpty() } .joinToString(separator = "\n") + +internal fun iterateSearchIndex(action: (SearchIndex) -> SearchIndex): SearchIndex { + var nextIndex = 0 + while (nextIndex != END_SEARCH) { + nextIndex = action(nextIndex) + } + return nextIndex +} \ No newline at end of file diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt index 0b19262..2c432c0 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt @@ -50,7 +50,7 @@ class RichMessageParserTest { @Test fun `replaces matrixdotto with person`() = runParserTest( - input = """Hello a-name: world""", expected = RichText(setOf(Normal("Hello "), Person(aUserId("@a-name:foo.bar"), "@a-name"), Normal(" world"))) ) From 918f186560c4449b37dc1c3874acd3493146684c Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 29 Oct 2022 13:59:29 +0100 Subject: [PATCH 19/28] allowing duplicate text content and handling header line breaks --- .../main/kotlin/app/dapk/st/core/RichText.kt | 2 +- .../app/dapk/st/design/components/Bubble.kt | 2 +- .../app/dapk/st/messenger/MessengerScreen.kt | 2 +- .../app/dapk/st/matrix/common/RichText.kt | 4 +- .../sync/internal/sync/message/HtmlParser.kt | 12 ++-- .../sync/internal/sync/message/PartBuilder.kt | 8 ++- .../internal/sync/RichMessageParserTest.kt | 64 ++++++++++--------- 7 files changed, 49 insertions(+), 45 deletions(-) diff --git a/core/src/main/kotlin/app/dapk/st/core/RichText.kt b/core/src/main/kotlin/app/dapk/st/core/RichText.kt index 18206d0..c330de0 100644 --- a/core/src/main/kotlin/app/dapk/st/core/RichText.kt +++ b/core/src/main/kotlin/app/dapk/st/core/RichText.kt @@ -1,6 +1,6 @@ package app.dapk.st.core -data class RichText(val parts: Set) { +data class RichText(val parts: List) { sealed interface Part { data class Normal(val content: String) : Part data class Link(val url: String, val label: String) : Part diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt index 3a8c5e7..b17e24f 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt @@ -30,7 +30,7 @@ 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"))) +private val ENCRYPTED_MESSAGE = RichText(listOf(RichText.Part.Normal("Encrypted message"))) sealed interface BubbleModel { val event: Event diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index c3452c1..2b2c186 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -238,7 +238,7 @@ private fun RichText.toApp(): app.dapk.st.core.RichText { is RichText.Part.Normal -> app.dapk.st.core.RichText.Part.Normal(it.content) is RichText.Part.Person -> app.dapk.st.core.RichText.Part.Person(it.userId.value) } - }.toSet()) + }) } @Composable diff --git a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt index b6f624c..2411c0b 100644 --- a/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt +++ b/matrix/common/src/main/kotlin/app/dapk/st/matrix/common/RichText.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class RichText(@SerialName("parts") val parts: Set) { +data class RichText(@SerialName("parts") val parts: List) { @Serializable sealed interface Part { @Serializable @@ -27,7 +27,7 @@ data class RichText(@SerialName("parts") val parts: Set) { } companion object { - fun of(text: String) = RichText(setOf(RichText.Part.Normal(text))) + fun of(text: String) = RichText(listOf(RichText.Part.Normal(text))) } } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt index 4c3f7b3..1f132b3 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt @@ -9,9 +9,7 @@ private val SKIPPED_TAGS = setOf("mx-reply") internal class HtmlParser { - fun test(startingFrom: Int, input: String): Int { - return input.indexOf(TAG_OPEN, startingFrom) - } + fun test(startingFrom: Int, input: String) = input.indexOf(TAG_OPEN, startingFrom) fun parseHtmlTags(input: String, searchIndex: Int, builder: PartBuilder, nestingLevel: Int = 0): SearchIndex = input.findTag( fromIndex = searchIndex, @@ -32,19 +30,19 @@ internal class HtmlParser { tagClose.next() } - else -> parseTagWithContent(tagName, input, tagClose, builder, searchIndex, tagOpen, wholeTag, nestingLevel) + else -> parseTagWithContent(input, tagName, tagClose, searchIndex, tagOpen, wholeTag, builder, nestingLevel) } } ) private fun parseTagWithContent( - tagName: String, input: String, + tagName: String, tagClose: Int, - builder: PartBuilder, searchIndex: Int, tagOpen: Int, wholeTag: String, + builder: PartBuilder, nestingLevel: Int ): Int { val exitTag = "" @@ -124,7 +122,7 @@ internal class HtmlParser { } "h1", "h2", "h3", "h4", "h5" -> { - builder.appendBold(tagContent) + builder.appendBold(tagContent.trim()) builder.appendNewline() exitTagCloseIndex } diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt index 3fd60bb..abc30ae 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt @@ -7,9 +7,11 @@ internal class PartBuilder { private var normalBuffer = StringBuilder() - private val parts = mutableSetOf() + private val parts = mutableListOf() fun appendText(value: String) { + println("append text") + normalBuffer.append(value.cleanFirstTextLine()) } @@ -35,11 +37,11 @@ internal class PartBuilder { parts.add(RichText.Part.Link(url, label ?: url)) } - fun build(): Set { + fun build(): List { flushNormalBuffer() val last = parts.last() if (last is RichText.Part.Normal) { - parts.remove(last) + parts.removeLast() val newContent = last.content.trimEnd() if (newContent.isNotEmpty()) { parts.add(last.copy(content = newContent)) diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt index 2c432c0..72b07a2 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt @@ -15,69 +15,73 @@ class RichMessageParserTest { @Test fun `parses plain text`() = runParserTest( input = "Hello world!", - expected = RichText(setOf(Normal("Hello world!"))) + expected = RichText(listOf(Normal("Hello world!"))) ) @Test fun `parses p tags`() = runParserTest( input = "

Hello world!

foo bar

after paragraph", - expected = RichText(setOf(Normal("Hello world!\nfoo bar\nafter paragraph"))) + expected = RichText(listOf(Normal("Hello world!\nfoo bar\nafter paragraph"))) ) @Test fun `parses nesting within p tags`() = runParserTest( input = "

Hello world!

", - expected = RichText(setOf(Bold("Hello world!"))) + expected = RichText(listOf(Bold("Hello world!"))) ) @Test fun `replaces quote entity`() = runParserTest( input = "Hello world! "foo bar"", - expected = RichText(setOf(Normal("Hello world! \"foo bar\""))) + expected = RichText(listOf(Normal("Hello world! \"foo bar\""))) ) @Test fun `replaces apostrophe entity`() = runParserTest( input = "Hello world! foo's bar", - expected = RichText(setOf(Normal("Hello world! foo's bar"))) + expected = RichText(listOf(Normal("Hello world! foo's bar"))) ) @Test fun `replaces people`() = runParserTest( input = "Hello <@my-name:a-domain.foo>!", - expected = RichText(setOf(Normal("Hello "), Person(aUserId("@my-name:a-domain.foo"), "@my-name:a-domain.foo"), Normal("!"))) + expected = RichText(listOf(Normal("Hello "), Person(aUserId("@my-name:a-domain.foo"), "@my-name:a-domain.foo"), Normal("!"))) ) @Test fun `replaces matrixdotto with person`() = runParserTest( input = """Hello a-name: world""", - expected = RichText(setOf(Normal("Hello "), Person(aUserId("@a-name:foo.bar"), "@a-name"), Normal(" world"))) + expected = RichText(listOf(Normal("Hello "), Person(aUserId("@a-name:foo.bar"), "@a-name"), Normal(" world"))) ) @Test fun `parses header tags`() = runParserTest( Case( input = "

hello

", - expected = RichText(setOf(Bold("hello"))) + expected = RichText(listOf(Bold("hello"))) ), Case( input = "

hello

text after title", - expected = RichText(setOf(Bold("hello"), Normal("\ntext after title"))) + expected = RichText(listOf(Bold("hello"), Normal("\ntext after title"))) ), Case( input = "

hello

", - expected = RichText(setOf(Bold("hello"))) + expected = RichText(listOf(Bold("hello"))) ), Case( input = "

hello

", - expected = RichText(setOf(Bold("hello"))) + expected = RichText(listOf(Bold("hello"))) + ), + Case( + input = "

1

\n

1

\n

1

\n", + expected = RichText(listOf(Bold("1"), Normal("\n\n"), Bold("1"), Normal("\n\n"), Bold("1"))) ), ) @Test fun `replaces br tags`() = runParserTest( input = "Hello world!
next line
another line", - expected = RichText(setOf(Normal("Hello world!\nnext line\nanother line"))) + expected = RichText(listOf(Normal("Hello world!\nnext line\nanother line"))) ) @@ -85,19 +89,19 @@ class RichMessageParserTest { fun `parses lists`() = runParserTest( Case( input = "
  • content in list item
  • another item in list
", - expected = RichText(setOf(Normal("- content in list item\n- another item in list"))) + expected = RichText(listOf(Normal("- content in list item\n- another item in list"))) ), Case( input = "
  1. content in list item
  2. another item in list
", - expected = RichText(setOf(Normal("1. content in list item\n2. another item in list"))) + expected = RichText(listOf(Normal("1. content in list item\n2. another item in list"))) ), Case( input = """
  1. content in list item
  2. another item in list
""", - expected = RichText(setOf(Normal("5. content in list item\n6. another item in list"))) + expected = RichText(listOf(Normal("5. content in list item\n6. another item in list"))) ), Case( input = """
  1. content in list item
  2. another item in list
  3. another change
  4. without value
""", - expected = RichText(setOf(Normal("3. content in list item\n4. another item in list\n10. another change\n11. without value"))) + expected = RichText(listOf(Normal("3. content in list item\n4. another item in list\n10. another change\n11. without value"))) ), ) @@ -105,19 +109,19 @@ class RichMessageParserTest { fun `parses urls`() = runParserTest( Case( input = "https://google.com", - expected = RichText(setOf(Link("https://google.com", "https://google.com"))) + expected = RichText(listOf(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"))) + expected = RichText(listOf(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("."))) + expected = RichText(listOf(Normal("ending sentence with url "), Link("https://google.com", "https://google.com"), Normal("."))) ), Case( input = "https://google.com
html after url", - expected = RichText(setOf(Link("https://google.com", "https://google.com"), Normal("\nhtml after url"))) + expected = RichText(listOf(Link("https://google.com", "https://google.com"), Normal("\nhtml after url"))) ), ) @@ -131,7 +135,7 @@ class RichMessageParserTest { Reply to message """.trimIndent(), - expected = RichText(setOf(Normal("Reply to message"))) + expected = RichText(listOf(Normal("Reply to message"))) ) @Test @@ -142,19 +146,19 @@ class RichMessageParserTest { Reply to message """.trimIndent(), - expected = RichText(setOf(Normal("Reply to message"))) + expected = RichText(listOf(Normal("Reply to message"))) ) @Test fun `parses styling text`() = runParserTest( input = "hello world", - expected = RichText(setOf(Italic("hello"), Normal(" "), Bold("world"))) + expected = RichText(listOf(Italic("hello"), Normal(" "), Bold("world"))) ) @Test fun `parses invalid tags text`() = runParserTest( input = ">> ><>> << more content", - expected = RichText(setOf(Normal(">> ><>> << more content"))) + expected = RichText(listOf(Normal(">> ><>> << more content"))) ) @Test @@ -162,7 +166,7 @@ class RichMessageParserTest { Case( input = """hello world""", expected = RichText( - setOf( + listOf( Normal("hello "), Bold("wor"), Normal("ld"), @@ -176,7 +180,7 @@ class RichMessageParserTest { Case( input = """hello world""", expected = RichText( - setOf( + listOf( Normal("hello "), Italic("wor"), Normal("ld"), @@ -191,7 +195,7 @@ class RichMessageParserTest { Case( input = """hello world""", expected = RichText( - setOf( + listOf( Normal("hello "), BoldItalic("wor"), Normal("ld"), @@ -201,7 +205,7 @@ class RichMessageParserTest { Case( input = """www.google.com""", expected = RichText( - setOf( + listOf( Link(url = "www.google.com", label = "www.google.com"), Link(url = "www.bing.com", label = "www.bing.com"), ) @@ -214,7 +218,7 @@ class RichMessageParserTest { Case( input = """hello world a link! more content.""", expected = RichText( - setOf( + listOf( Normal("hello world "), Link(url = "www.google.com", label = "a link!"), Normal(" more content."), @@ -224,7 +228,7 @@ class RichMessageParserTest { Case( input = """www.google.comwww.bing.com""", expected = RichText( - setOf( + listOf( Link(url = "www.google.com", label = "www.google.com"), Link(url = "www.bing.com", label = "www.bing.com"), ) From 7e7f17ac27f889491bfc3f47b19c9d935d9e56cf Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 29 Oct 2022 14:03:27 +0100 Subject: [PATCH 20/28] use asString helper when convering rich text to a string message --- .../src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt index 897d026..fc876fd 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt @@ -1,9 +1,6 @@ package app.dapk.st.engine -import app.dapk.st.matrix.common.CredentialsStore -import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.common.RoomMember -import app.dapk.st.matrix.common.UserId +import app.dapk.st.matrix.common.* import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.sync.RoomStore @@ -68,7 +65,7 @@ internal class DirectoryUseCase( this.copy( lastMessage = RoomOverview.LastMessage( content = when (val message = latestEcho.message) { - is MessageService.Message.TextMessage -> message.content.body.parts.joinToString("") + is MessageService.Message.TextMessage -> message.content.body.asString() is MessageService.Message.ImageMessage -> "\uD83D\uDCF7" }, utcTimestamp = latestEcho.timestampUtc, From 2ad4ca1c61265de126b8aff0a4cdc9b2c84f43c0 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 30 Oct 2022 18:23:05 +0000 Subject: [PATCH 21/28] add fullscreen image viewing when tapping image --- .../app/dapk/st/design/components/Bubble.kt | 23 ++-- .../app/dapk/st/design/components/Toolbar.kt | 4 +- .../app/dapk/st/messenger/MessengerScreen.kt | 108 +++++++++++++++++- .../app/dapk/st/messenger/MessengerState.kt | 6 + .../dapk/st/messenger/MessengerViewModel.kt | 15 ++- .../st/messenger/MessengerViewModelTest.kt | 6 +- 6 files changed, 146 insertions(+), 16 deletions(-) diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt index b17e24f..f03c16d 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt @@ -47,24 +47,29 @@ sealed interface BubbleModel { data class Event(val authorId: String, val authorName: String, val edited: Boolean, val time: String) + + data class Action( + val onLongClick: (BubbleModel) -> Unit, + val onImageClick: (Image) -> Unit, + ) } private fun BubbleModel.Reply.isReplyingToSelf() = this.replyingTo.event.authorId == this.reply.event.authorId @Composable -fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, onLongClick: (BubbleModel) -> Unit) { - val itemisedLongClick = { onLongClick.invoke(model) } +fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, actions: BubbleModel.Action) { + val itemisedLongClick = { actions.onLongClick.invoke(model) } when (model) { is BubbleModel.Text -> TextBubble(bubble, model, status, itemisedLongClick) is BubbleModel.Encrypted -> EncryptedBubble(bubble, model, status, itemisedLongClick) - is BubbleModel.Image -> ImageBubble(bubble, model, status, itemisedLongClick) + is BubbleModel.Image -> ImageBubble(bubble, model, status, onItemClick = { actions.onImageClick(model) }, itemisedLongClick) is BubbleModel.Reply -> ReplyBubble(bubble, model, status, itemisedLongClick) } } @Composable private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) { - Bubble(bubble, onLongClick) { + Bubble(bubble, onItemClick = {}, onLongClick) { if (bubble.isNotSelf()) { AuthorName(model.event, bubble) } @@ -79,8 +84,8 @@ private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, st } @Composable -private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onLongClick: () -> Unit) { - Bubble(bubble, onLongClick) { +private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onItemClick: () -> Unit, onLongClick: () -> Unit) { + Bubble(bubble, onItemClick, onLongClick) { if (bubble.isNotSelf()) { AuthorName(model.event, bubble) } @@ -97,7 +102,7 @@ private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @C @Composable private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) { - Bubble(bubble, onLongClick) { + Bubble(bubble, onItemClick = {}, onLongClick) { Column( Modifier .fillMaxWidth() @@ -204,7 +209,7 @@ private fun Int.scalerFor(max: Float): Float { @OptIn(ExperimentalFoundationApi::class) @Composable -private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Composable () -> Unit) { +private fun Bubble(bubble: BubbleMeta, onItemClick: () -> Unit, onLongClick: () -> Unit, content: @Composable () -> Unit) { Box(modifier = Modifier.padding(start = 6.dp)) { Box( Modifier @@ -212,7 +217,7 @@ private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Compos .clip(bubble.shape) .background(bubble.background) .height(IntrinsicSize.Max) - .combinedClickable(onLongClick = onLongClick, onClick = {}), + .combinedClickable(onLongClick = onLongClick, onClick = onItemClick), ) { Column( Modifier diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt index ec0010e..efcb2c1 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset @@ -16,13 +17,14 @@ fun Toolbar( onNavigate: (() -> Unit)? = null, title: String? = null, offset: (Density.() -> IntOffset)? = null, + color: Color = MaterialTheme.colorScheme.background, actions: @Composable RowScope.() -> Unit = {} ) { val navigationIcon = foo(onNavigate) TopAppBar( modifier = offset?.let { Modifier.offset(it) } ?: Modifier, colors = TopAppBarDefaults.smallTopAppBarColors( - containerColor = MaterialTheme.colorScheme.background + containerColor = color, ), navigationIcon = navigationIcon, title = title?.let { { Text(it, maxLines = 2) } } ?: {}, diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index 2b2c186..d8e0486 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -1,6 +1,7 @@ package app.dapk.st.messenger import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.activity.result.ActivityResultLauncher import androidx.compose.animation.* import androidx.compose.animation.core.tween @@ -8,6 +9,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.CircleShape @@ -24,7 +26,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -51,6 +57,8 @@ import app.dapk.st.navigator.Navigator import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import kotlinx.coroutines.launch +import kotlin.math.min +import kotlin.math.roundToInt @Composable internal fun MessengerScreen( @@ -76,7 +84,8 @@ internal fun MessengerScreen( val messageActions = MessageActions( onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) }, onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) }, - onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) } + onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) }, + onImageClick = { viewModel.selectImage(it) } ) Column { @@ -85,6 +94,7 @@ internal fun MessengerScreen( // DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {}) // } }) + when (state.composerState) { is ComposerState.Text -> { Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) }) @@ -106,6 +116,86 @@ internal fun MessengerScreen( } } } + + when (state.viewerState) { + null -> { + // do nothing + } + + else -> { + Box(Modifier.fillMaxSize().background(Color.Black)) { + BackHandler(onBack = { viewModel.unselectImage() }) + ZoomableImage(state.viewerState) + Toolbar( + onNavigate = { viewModel.unselectImage() }, + title = state.viewerState.event.event.authorName, + color = Color.Black.copy(alpha = 0.4f), + ) + } + } + } +} + +@Composable +private fun ZoomableImage(viewerState: ViewerState) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val angle by remember { mutableStateOf(0f) } + var zoom by remember { mutableStateOf(1f) } + var offsetX by remember { mutableStateOf(0f) } + var offsetY by remember { mutableStateOf(0f) } + + val screenWidth = constraints.maxWidth + val screenHeight = constraints.maxHeight + + val renderedSize = remember { + val imageContent = viewerState.event.imageContent + val imageHeight = imageContent.height ?: 120 + val heightScaleFactor = screenHeight.toFloat() / imageHeight.toFloat() + val imageWidth = imageContent.width ?: 120 + val widthScaleFactor = screenWidth.toFloat() / imageWidth.toFloat() + val scaler = min(heightScaleFactor, widthScaleFactor) + IntSize((imageWidth * scaler).roundToInt(), (imageHeight * scaler).roundToInt()) + } + + Image( + painter = rememberAsyncImagePainter(model = viewerState.event.imageRequest), + contentDescription = "", + contentScale = ContentScale.Fit, + modifier = Modifier + .graphicsLayer { + scaleX = zoom + scaleY = zoom + rotationZ = angle + translationX = offsetX + translationY = offsetY + } + .pointerInput(Unit) { + detectTransformGestures( + onGesture = { _, pan, gestureZoom, _ -> + zoom = (zoom * gestureZoom).coerceIn(1F..4F) + if (zoom > 1) { + val x = (pan.x * zoom) + val y = (pan.y * zoom) + + if (renderedSize.width * zoom > screenWidth) { + val maxZoomedWidthOffset = ((renderedSize.width * zoom) - screenWidth) / 2 + offsetX = (offsetX + x).coerceIn(-maxZoomedWidthOffset..maxZoomedWidthOffset) + } + + if (renderedSize.height * zoom > screenHeight) { + val maxZoomedHeightOffset = ((renderedSize.height * zoom) - screenHeight) / 2 + offsetY = (offsetY + y).coerceIn(-maxZoomedHeightOffset..maxZoomedHeightOffset) + } + } else { + offsetX = 0F + offsetY = 0F + } + } + ) + } + .fillMaxSize() + ) + } } @Composable @@ -180,6 +270,11 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio } } + val bubbleActions = BubbleModel.Action( + onLongClick = { messageActions.onLongClick(it) }, + onImageClick = { messageActions.onImageClick(it) } + ) + LazyColumn( modifier = Modifier .fillMaxWidth() @@ -201,7 +296,7 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio onReply = { messageActions.onReply(item) }, ) { val status = @Composable { SendStatus(item) } - MessageBubble(this, item.toModel(), status, onLongClick = messageActions.onLongClick) + MessageBubble(this, item.toModel(), status, bubbleActions) } } } @@ -283,7 +378,13 @@ private fun SendStatus(message: RoomEvent) { @OptIn(ExperimentalAnimationApi::class) @Composable -private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit, messageActions: MessageActions) { +private fun TextComposer( + state: ComposerState.Text, + onTextChange: (String) -> Unit, + onSend: () -> Unit, + onAttach: () -> Unit, + messageActions: MessageActions +) { Row( Modifier .fillMaxWidth() @@ -447,4 +548,5 @@ class MessageActions( val onReply: (RoomEvent) -> Unit, val onDismiss: () -> Unit, val onLongClick: (BubbleModel) -> Unit, + val onImageClick: (BubbleModel.Image) -> Unit, ) diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt index fb50fc9..60657a9 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt @@ -1,6 +1,7 @@ package app.dapk.st.messenger import app.dapk.st.core.Lce +import app.dapk.st.design.components.BubbleModel import app.dapk.st.engine.MessengerState import app.dapk.st.engine.RoomEvent import app.dapk.st.matrix.common.RoomId @@ -10,6 +11,11 @@ data class MessengerScreenState( val roomId: RoomId?, val roomState: Lce, val composerState: ComposerState, + val viewerState: ViewerState? +) + +data class ViewerState( + val event: BubbleModel.Image, ) sealed interface MessengerEvent { diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt index 257599b..460257d 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt @@ -32,7 +32,8 @@ internal class MessengerViewModel( initialState = MessengerScreenState( roomId = null, roomState = Lce.Loading(), - composerState = ComposerState.Text(value = "", reply = null) + composerState = ComposerState.Text(value = "", reply = null), + viewerState = null, ), factory = factory, ) { @@ -157,6 +158,18 @@ internal class MessengerViewModel( } } + fun selectImage(image: BubbleModel.Image) { + updateState { + copy(viewerState = ViewerState(image)) + } + } + + fun unselectImage() { + updateState { + copy(viewerState = null) + } + } + } private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) { diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt index ee605bf..c152194 100644 --- a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt @@ -48,7 +48,8 @@ class MessengerViewModelTest { MessengerScreenState( roomId = null, roomState = Lce.Loading(), - composerState = ComposerState.Text(value = "", reply = null) + composerState = ComposerState.Text(value = "", reply = null), + viewerState = null, ) ) } @@ -114,7 +115,8 @@ class MessengerViewModelTest { fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState( roomId = roomId, roomState = Lce.Content(roomState), - composerState = ComposerState.Text(value = messageContent ?: "", reply = null) + composerState = ComposerState.Text(value = messageContent ?: "", reply = null), + viewerState = null, ) class FakeCopyToClipboard { From a0e56b349c927d628b4f48edfbe2563a9f30e38a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 30 Oct 2022 18:40:50 +0000 Subject: [PATCH 22/28] fix images using wrong width/height - takes the exit rotation into account as the bitmap can provide misleading values --- app/src/main/kotlin/app/dapk/st/graph/AppModule.kt | 10 ++++++++-- .../st/matrix/message/internal/SendMessageUseCase.kt | 1 - 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index 47caa4c..716910c 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -6,6 +6,7 @@ import android.content.ContentResolver import android.content.Context import android.content.Intent import android.graphics.BitmapFactory +import android.media.ExifInterface import android.net.Uri import android.os.Build import android.provider.OpenableColumns @@ -295,9 +296,14 @@ internal class AndroidImageContentReader(private val contentResolver: ContentRes cursor.getLong(columnIndex) } ?: throw IllegalArgumentException("Could not process $uri") + val shouldSwapSizes = ExifInterface(contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri")).let { + val orientation = it.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED) + orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270 + } + return ImageContentReader.ImageContent( - height = options.outHeight, - width = options.outWidth, + height = if (shouldSwapSizes) options.outWidth else options.outHeight, + width = if (shouldSwapSizes) options.outHeight else options.outWidth, size = fileSize, mimeType = options.outMimeType, fileName = androidUri.lastPathSegment ?: "file", diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt index f070232..8241ac9 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/internal/SendMessageUseCase.kt @@ -60,7 +60,6 @@ internal class SendMessageUseCase( private suspend fun imageMessageRequest(message: Message.ImageMessage): HttpRequest { val imageMeta = message.content.meta - return when (message.sendEncrypted) { true -> { val result = mediaEncrypter.encrypt(imageContentReader.inputStream(message.content.uri)) From 2e2e4aff990d93278dbbbd08905b9003c6569581 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 30 Oct 2022 18:42:22 +0000 Subject: [PATCH 23/28] use matching colour for attachment icon and message hint --- .../src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index d8e0486..f6f4773 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -467,6 +467,7 @@ private fun TextComposer( modifier = Modifier.clickable { onAttach() }.wrapContentWidth().align(Alignment.Bottom), imageVector = Icons.Filled.Image, contentDescription = "", + tint = SmallTalkTheme.extendedColors.onOthersBubble.copy(alpha = 0.5f), ) } } From 5b7fe5aa4efb4debfe3c25750b2cc5494f24f0dc Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 30 Oct 2022 19:34:34 +0000 Subject: [PATCH 24/28] block application launch setup until beta upgrades have completed --- .../app/dapk/st/SmallTalkApplication.kt | 5 ++- .../kotlin/app/dapk/st/graph/AppModule.kt | 12 ++++++- .../dapk/st/home/BetaVersionUpgradeUseCase.kt | 36 ++++++++++++++----- .../kotlin/app/dapk/st/home/HomeModule.kt | 8 ++--- .../kotlin/app/dapk/st/home/HomeViewModel.kt | 2 +- 5 files changed, 44 insertions(+), 19 deletions(-) diff --git a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt index 8d906ef..90a3a50 100644 --- a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt +++ b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt @@ -60,13 +60,12 @@ class SmallTalkApplication : Application(), ModuleProvider { private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) { applicationScope.launch { + featureModules.homeModule.betaVersionUpgradeUseCase.waitUnitReady() + storeModule.credentialsStore().credentials()?.let { featureModules.pushModule.pushTokenRegistrar().registerCurrentToken() } runCatching { storeModule.localEchoStore.preload() } - } - - applicationScope.launch { val notificationsUseCase = notificationsModule.notificationsUseCase() notificationsUseCase.listenForNotificationChanges(this) } diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index 716910c..940c2f1 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -20,6 +20,7 @@ import app.dapk.st.directory.DirectoryModule import app.dapk.st.domain.StoreModule import app.dapk.st.engine.MatrixEngine import app.dapk.st.firebase.messaging.MessagingModule +import app.dapk.st.home.BetaVersionUpgradeUseCase import app.dapk.st.home.HomeModule import app.dapk.st.home.MainActivity import app.dapk.st.imageloader.ImageLoaderModule @@ -164,7 +165,16 @@ internal class FeatureModules internal constructor( deviceMeta, ) } - val homeModule by unsafeLazy { HomeModule(chatEngineModule.engine, storeModule.value, buildMeta) } + val homeModule by unsafeLazy { + HomeModule( + chatEngineModule.engine, + storeModule.value, + BetaVersionUpgradeUseCase( + storeModule.value.applicationStore(), + buildMeta, + ), + ) + } val settingsModule by unsafeLazy { SettingsModule( chatEngineModule.engine, diff --git a/features/home/src/main/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCase.kt b/features/home/src/main/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCase.kt index 7897223..4a1168a 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCase.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/BetaVersionUpgradeUseCase.kt @@ -3,24 +3,44 @@ package app.dapk.st.home import app.dapk.st.core.BuildMeta import app.dapk.st.domain.ApplicationPreferences import app.dapk.st.domain.ApplicationVersion +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume class BetaVersionUpgradeUseCase( private val applicationPreferences: ApplicationPreferences, private val buildMeta: BuildMeta, ) { + private var _continuation: CancellableContinuation? = null + fun hasVersionChanged(): Boolean { - return runBlocking { - val previousVersion = applicationPreferences.readVersion()?.value - val currentVersion = buildMeta.versionCode - when (previousVersion) { - null -> false - else -> currentVersion > previousVersion - }.also { - applicationPreferences.setVersion(ApplicationVersion(currentVersion)) + return runBlocking { hasChangedVersion() } + } + + private suspend fun hasChangedVersion(): Boolean { + val previousVersion = applicationPreferences.readVersion()?.value + val currentVersion = buildMeta.versionCode + return when (previousVersion) { + null -> false + else -> currentVersion > previousVersion + }.also { + applicationPreferences.setVersion(ApplicationVersion(currentVersion)) + } + } + + suspend fun waitUnitReady() { + if (hasChangedVersion()) { + suspendCancellableCoroutine { continuation -> + _continuation = continuation } } } + fun notifyUpgraded() { + _continuation?.resume(Unit) + _continuation = null + } + } \ No newline at end of file diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt index 210b5aa..f132898 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt @@ -1,6 +1,5 @@ package app.dapk.st.home -import app.dapk.st.core.BuildMeta import app.dapk.st.core.ProvidableModule import app.dapk.st.directory.DirectoryViewModel import app.dapk.st.domain.StoreModule @@ -11,7 +10,7 @@ import app.dapk.st.profile.ProfileViewModel class HomeModule( private val chatEngine: ChatEngine, private val storeModule: StoreModule, - private val buildMeta: BuildMeta, + val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase, ) : ProvidableModule { fun homeViewModel(directory: DirectoryViewModel, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel { @@ -22,10 +21,7 @@ class HomeModule( login, profileViewModel, storeModule.cacheCleaner(), - BetaVersionUpgradeUseCase( - storeModule.applicationStore(), - buildMeta, - ), + betaVersionUpgradeUseCase, ) } diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt index a18e8e8..719f41a 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt @@ -12,7 +12,6 @@ import app.dapk.st.profile.ProfileViewModel import app.dapk.st.viewmodel.DapkViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -87,6 +86,7 @@ class HomeViewModel( fun clearCache() { viewModelScope.launch { cacheCleaner.cleanCache(removeCredentials = false) + betaVersionUpgradeUseCase.notifyUpgraded() _events.emit(HomeEvent.Relaunch) } } From e38cbc89f0e9578365f3e8f44bcc68be7f35d237 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Oct 2022 05:27:56 +0000 Subject: [PATCH 25/28] Bump ktorVer from 2.1.2 to 2.1.3 Bumps `ktorVer` from 2.1.2 to 2.1.3. Updates `ktor-client-android` from 2.1.2 to 2.1.3 - [Release notes](https://github.com/ktorio/ktor/releases) - [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md) - [Commits](https://github.com/ktorio/ktor/compare/2.1.2...2.1.3) Updates `ktor-client-core` from 2.1.2 to 2.1.3 - [Release notes](https://github.com/ktorio/ktor/releases) - [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md) - [Commits](https://github.com/ktorio/ktor/compare/2.1.2...2.1.3) Updates `ktor-client-serialization` from 2.1.2 to 2.1.3 - [Release notes](https://github.com/ktorio/ktor/releases) - [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md) - [Commits](https://github.com/ktorio/ktor/compare/2.1.2...2.1.3) Updates `ktor-serialization-kotlinx-json` from 2.1.2 to 2.1.3 - [Release notes](https://github.com/ktorio/ktor/releases) - [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md) - [Commits](https://github.com/ktorio/ktor/compare/2.1.2...2.1.3) Updates `ktor-client-logging-jvm` from 2.1.2 to 2.1.3 - [Release notes](https://github.com/ktorio/ktor/releases) - [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md) - [Commits](https://github.com/ktorio/ktor/compare/2.1.2...2.1.3) Updates `ktor-client-java` from 2.1.2 to 2.1.3 - [Release notes](https://github.com/ktorio/ktor/releases) - [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md) - [Commits](https://github.com/ktorio/ktor/compare/2.1.2...2.1.3) Updates `ktor-client-content-negotiation` from 2.1.2 to 2.1.3 - [Release notes](https://github.com/ktorio/ktor/releases) - [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md) - [Commits](https://github.com/ktorio/ktor/compare/2.1.2...2.1.3) --- updated-dependencies: - dependency-name: io.ktor:ktor-client-android dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.ktor:ktor-client-core dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.ktor:ktor-client-serialization dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.ktor:ktor-serialization-kotlinx-json dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.ktor:ktor-client-logging-jvm dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.ktor:ktor-client-java dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.ktor:ktor-client-content-negotiation dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index b4ca17c..30118bf 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -100,7 +100,7 @@ ext.Dependencies.with { def kotlinVer = "1.7.20" def sqldelightVer = "1.5.4" def composeVer = "1.2.1" - def ktorVer = "2.1.2" + def ktorVer = "2.1.3" google = new DependenciesContainer() google.with { From e5ae1495bcf14c1ad11a909a5d95210b322030b4 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 31 Oct 2022 18:14:41 +0000 Subject: [PATCH 26/28] updating android gradle plugin to latest --- dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index 30118bf..2623c1c 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -104,7 +104,7 @@ ext.Dependencies.with { google = new DependenciesContainer() google.with { - androidGradlePlugin = "com.android.tools.build:gradle:7.3.0" + androidGradlePlugin = "com.android.tools.build:gradle:7.3.1" androidxComposeUi = "androidx.compose.ui:ui:${composeVer}" androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}" From 0fbef88c2d87c2c58bd623e739774775a9d7d328 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 31 Oct 2022 18:25:38 +0000 Subject: [PATCH 27/28] adding support for blockquotes --- .../sync/internal/sync/message/HtmlParser.kt | 19 +++++++++++++++++++ .../sync/internal/sync/message/PartBuilder.kt | 2 -- .../internal/sync/RichMessageParserTest.kt | 5 +++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt index 1f132b3..e43ab24 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/HtmlParser.kt @@ -99,6 +99,25 @@ internal class HtmlParser { exitTagCloseIndex } + "blockquote" -> { + if (tagContent.isNotEmpty() && nestingLevel < 3) { + var lastIndex = 0 + val trimmedTagContent = tagContent.trim() + builder.appendText("> ") + iterateSearchIndex { searchIndex -> + lastIndex = searchIndex + parseHtmlTags(trimmedTagContent, searchIndex, builder, nestingLevel = nestingLevel + 1) + } + + if (lastIndex < trimmedTagContent.length) { + builder.appendText(trimmedTagContent.substring(lastIndex)) + } + } + + builder.appendNewline() + exitTagCloseIndex + } + "p" -> { if (tagContent.isNotEmpty() && nestingLevel < 2) { var lastIndex = 0 diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt index abc30ae..2cbf4e1 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/message/PartBuilder.kt @@ -10,8 +10,6 @@ internal class PartBuilder { private val parts = mutableListOf() fun appendText(value: String) { - println("append text") - normalBuffer.append(value.cleanFirstTextLine()) } diff --git a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt index 72b07a2..e910a07 100644 --- a/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt +++ b/matrix/services/sync/src/test/kotlin/app/dapk/st/matrix/sync/internal/sync/RichMessageParserTest.kt @@ -84,6 +84,11 @@ class RichMessageParserTest { expected = RichText(listOf(Normal("Hello world!\nnext line\nanother line"))) ) + @Test + fun `parses blockquote tags`() = runParserTest( + input = "
\n

hello world

\n
\n", + expected = RichText(listOf(Normal("> "), Bold("hello"), Normal(" "), Italic("world"))) + ) @Test fun `parses lists`() = runParserTest( From e9698cf66f14837511709fe3652b5746cccffc35 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 31 Oct 2022 19:03:55 +0000 Subject: [PATCH 28/28] updating version for release --- version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.json b/version.json index c1c3cb6..20b9df2 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "code": 24, - "name": "27/10/2022-V1" + "code": 25, + "name": "31/10/2022-V1" } \ No newline at end of file