mirror of
https://github.com/ouchadam/small-talk.git
synced 2025-02-03 12:57:32 +01:00
Merge pull request #232 from ouchadam/release-candidate
[Auto] Release Candidate
This commit is contained in:
commit
58730470f6
@ -60,13 +60,12 @@ class SmallTalkApplication : Application(), ModuleProvider {
|
|||||||
|
|
||||||
private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) {
|
private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) {
|
||||||
applicationScope.launch {
|
applicationScope.launch {
|
||||||
|
featureModules.homeModule.betaVersionUpgradeUseCase.waitUnitReady()
|
||||||
|
|
||||||
storeModule.credentialsStore().credentials()?.let {
|
storeModule.credentialsStore().credentials()?.let {
|
||||||
featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
|
featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
|
||||||
}
|
}
|
||||||
runCatching { storeModule.localEchoStore.preload() }
|
runCatching { storeModule.localEchoStore.preload() }
|
||||||
}
|
|
||||||
|
|
||||||
applicationScope.launch {
|
|
||||||
val notificationsUseCase = notificationsModule.notificationsUseCase()
|
val notificationsUseCase = notificationsModule.notificationsUseCase()
|
||||||
notificationsUseCase.listenForNotificationChanges(this)
|
notificationsUseCase.listenForNotificationChanges(this)
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import android.content.ContentResolver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.media.ExifInterface
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
@ -19,6 +20,7 @@ import app.dapk.st.directory.DirectoryModule
|
|||||||
import app.dapk.st.domain.StoreModule
|
import app.dapk.st.domain.StoreModule
|
||||||
import app.dapk.st.engine.MatrixEngine
|
import app.dapk.st.engine.MatrixEngine
|
||||||
import app.dapk.st.firebase.messaging.MessagingModule
|
import app.dapk.st.firebase.messaging.MessagingModule
|
||||||
|
import app.dapk.st.home.BetaVersionUpgradeUseCase
|
||||||
import app.dapk.st.home.HomeModule
|
import app.dapk.st.home.HomeModule
|
||||||
import app.dapk.st.home.MainActivity
|
import app.dapk.st.home.MainActivity
|
||||||
import app.dapk.st.imageloader.ImageLoaderModule
|
import app.dapk.st.imageloader.ImageLoaderModule
|
||||||
@ -163,7 +165,16 @@ internal class FeatureModules internal constructor(
|
|||||||
deviceMeta,
|
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 {
|
val settingsModule by unsafeLazy {
|
||||||
SettingsModule(
|
SettingsModule(
|
||||||
chatEngineModule.engine,
|
chatEngineModule.engine,
|
||||||
@ -295,9 +306,14 @@ internal class AndroidImageContentReader(private val contentResolver: ContentRes
|
|||||||
cursor.getLong(columnIndex)
|
cursor.getLong(columnIndex)
|
||||||
} ?: throw IllegalArgumentException("Could not process $uri")
|
} ?: 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(
|
return ImageContentReader.ImageContent(
|
||||||
height = options.outHeight,
|
height = if (shouldSwapSizes) options.outWidth else options.outHeight,
|
||||||
width = options.outWidth,
|
width = if (shouldSwapSizes) options.outHeight else options.outWidth,
|
||||||
size = fileSize,
|
size = fileSize,
|
||||||
mimeType = options.outMimeType,
|
mimeType = options.outMimeType,
|
||||||
fileName = androidUri.lastPathSegment ?: "file",
|
fileName = androidUri.lastPathSegment ?: "file",
|
||||||
|
@ -125,7 +125,7 @@ sealed class RoomEvent {
|
|||||||
data class Message(
|
data class Message(
|
||||||
override val eventId: EventId,
|
override val eventId: EventId,
|
||||||
override val utcTimestamp: Long,
|
override val utcTimestamp: Long,
|
||||||
val content: String,
|
val content: RichText,
|
||||||
override val author: RoomMember,
|
override val author: RoomMember,
|
||||||
override val meta: MessageMeta,
|
override val meta: MessageMeta,
|
||||||
override val edited: Boolean = false,
|
override val edited: Boolean = false,
|
||||||
|
@ -23,7 +23,7 @@ fun aRoomOverview(
|
|||||||
fun anEncryptedRoomMessageEvent(
|
fun anEncryptedRoomMessageEvent(
|
||||||
eventId: EventId = anEventId(),
|
eventId: EventId = anEventId(),
|
||||||
utcTimestamp: Long = 0L,
|
utcTimestamp: Long = 0L,
|
||||||
content: String = "encrypted-content",
|
content: RichText = RichText.of("encrypted-content"),
|
||||||
author: RoomMember = aRoomMember(),
|
author: RoomMember = aRoomMember(),
|
||||||
meta: MessageMeta = MessageMeta.FromServer,
|
meta: MessageMeta = MessageMeta.FromServer,
|
||||||
edited: Boolean = false,
|
edited: Boolean = false,
|
||||||
@ -47,7 +47,7 @@ fun aRoomReplyMessageEvent(
|
|||||||
fun aRoomMessageEvent(
|
fun aRoomMessageEvent(
|
||||||
eventId: EventId = anEventId(),
|
eventId: EventId = anEventId(),
|
||||||
utcTimestamp: Long = 0L,
|
utcTimestamp: Long = 0L,
|
||||||
content: String = "message-content",
|
content: RichText = RichText.of("message-content"),
|
||||||
author: RoomMember = aRoomMember(),
|
author: RoomMember = aRoomMember(),
|
||||||
meta: MessageMeta = MessageMeta.FromServer,
|
meta: MessageMeta = MessageMeta.FromServer,
|
||||||
edited: Boolean = false,
|
edited: Boolean = false,
|
||||||
|
23
core/src/main/kotlin/app/dapk/st/core/RichText.kt
Normal file
23
core/src/main/kotlin/app/dapk/st/core/RichText.kt
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package app.dapk.st.core
|
||||||
|
|
||||||
|
data class RichText(val parts: List<Part>) {
|
||||||
|
sealed interface Part {
|
||||||
|
data class Normal(val content: String) : Part
|
||||||
|
data class Link(val url: String, val label: String) : Part
|
||||||
|
data class Bold(val content: String) : Part
|
||||||
|
data class Italic(val content: String) : Part
|
||||||
|
data class BoldItalic(val content: String) : Part
|
||||||
|
data class Person(val displayName: String) : Part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
@ -100,11 +100,11 @@ ext.Dependencies.with {
|
|||||||
def kotlinVer = "1.7.20"
|
def kotlinVer = "1.7.20"
|
||||||
def sqldelightVer = "1.5.4"
|
def sqldelightVer = "1.5.4"
|
||||||
def composeVer = "1.2.1"
|
def composeVer = "1.2.1"
|
||||||
def ktorVer = "2.1.2"
|
def ktorVer = "2.1.3"
|
||||||
|
|
||||||
google = new DependenciesContainer()
|
google = new DependenciesContainer()
|
||||||
google.with {
|
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}"
|
androidxComposeUi = "androidx.compose.ui:ui:${composeVer}"
|
||||||
androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}"
|
androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}"
|
||||||
@ -143,7 +143,7 @@ ext.Dependencies.with {
|
|||||||
ktorContentNegotiation = "io.ktor:ktor-client-content-negotiation:${ktorVer}"
|
ktorContentNegotiation = "io.ktor:ktor-client-content-negotiation:${ktorVer}"
|
||||||
|
|
||||||
coil = "io.coil-kt:coil-compose:2.2.2"
|
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"
|
junit = "junit:junit:4.13.2"
|
||||||
kluent = "org.amshove.kluent:kluent:1.72"
|
kluent = "org.amshove.kluent:kluent:1.72"
|
||||||
|
@ -7,6 +7,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@ -15,18 +16,26 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
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.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.DpSize
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import app.dapk.st.core.RichText
|
||||||
import coil.compose.rememberAsyncImagePainter
|
import coil.compose.rememberAsyncImagePainter
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
|
|
||||||
|
private val ENCRYPTED_MESSAGE = RichText(listOf(RichText.Part.Normal("Encrypted message")))
|
||||||
|
|
||||||
sealed interface BubbleModel {
|
sealed interface BubbleModel {
|
||||||
val event: Event
|
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 Encrypted(override val event: Event) : BubbleModel
|
||||||
data class Image(val imageContent: ImageContent, val imageRequest: ImageRequest, 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)
|
data class ImageContent(val width: Int?, val height: Int?, val url: String)
|
||||||
@ -38,24 +47,29 @@ sealed interface BubbleModel {
|
|||||||
|
|
||||||
data class Event(val authorId: String, val authorName: String, val edited: Boolean, val time: String)
|
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
|
private fun BubbleModel.Reply.isReplyingToSelf() = this.replyingTo.event.authorId == this.reply.event.authorId
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, onLongClick: (BubbleModel) -> Unit) {
|
fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, actions: BubbleModel.Action) {
|
||||||
val itemisedLongClick = { onLongClick.invoke(model) }
|
val itemisedLongClick = { actions.onLongClick.invoke(model) }
|
||||||
when (model) {
|
when (model) {
|
||||||
is BubbleModel.Text -> TextBubble(bubble, model, status, itemisedLongClick)
|
is BubbleModel.Text -> TextBubble(bubble, model, status, itemisedLongClick)
|
||||||
is BubbleModel.Encrypted -> EncryptedBubble(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)
|
is BubbleModel.Reply -> ReplyBubble(bubble, model, status, itemisedLongClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
||||||
Bubble(bubble, onLongClick) {
|
Bubble(bubble, onItemClick = {}, onLongClick) {
|
||||||
if (bubble.isNotSelf()) {
|
if (bubble.isNotSelf()) {
|
||||||
AuthorName(model.event, bubble)
|
AuthorName(model.event, bubble)
|
||||||
}
|
}
|
||||||
@ -66,12 +80,12 @@ private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Com
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
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
|
@Composable
|
||||||
private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onItemClick: () -> Unit, onLongClick: () -> Unit) {
|
||||||
Bubble(bubble, onLongClick) {
|
Bubble(bubble, onItemClick, onLongClick) {
|
||||||
if (bubble.isNotSelf()) {
|
if (bubble.isNotSelf()) {
|
||||||
AuthorName(model.event, bubble)
|
AuthorName(model.event, bubble)
|
||||||
}
|
}
|
||||||
@ -88,7 +102,7 @@ private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @C
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
||||||
Bubble(bubble, onLongClick) {
|
Bubble(bubble, onItemClick = {}, onLongClick) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -111,7 +125,7 @@ private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @C
|
|||||||
when (val replyingTo = model.replyingTo) {
|
when (val replyingTo = model.replyingTo) {
|
||||||
is BubbleModel.Text -> {
|
is BubbleModel.Text -> {
|
||||||
Text(
|
Text(
|
||||||
text = replyingTo.content,
|
text = replyingTo.content.toAnnotatedText(),
|
||||||
color = bubble.textColor().copy(alpha = 0.8f),
|
color = bubble.textColor().copy(alpha = 0.8f),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.wrapContentSize(),
|
modifier = Modifier.wrapContentSize(),
|
||||||
@ -153,7 +167,7 @@ private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @C
|
|||||||
|
|
||||||
when (val message = model.reply) {
|
when (val message = model.reply) {
|
||||||
is BubbleModel.Text -> TextContent(bubble, message.content)
|
is BubbleModel.Text -> TextContent(bubble, message.content)
|
||||||
is BubbleModel.Encrypted -> TextContent(bubble, "Encrypted message")
|
is BubbleModel.Encrypted -> TextContent(bubble, ENCRYPTED_MESSAGE)
|
||||||
is BubbleModel.Image -> {
|
is BubbleModel.Image -> {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Image(
|
Image(
|
||||||
@ -195,7 +209,7 @@ private fun Int.scalerFor(max: Float): Float {
|
|||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@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 = Modifier.padding(start = 6.dp)) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@ -203,7 +217,7 @@ private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Compos
|
|||||||
.clip(bubble.shape)
|
.clip(bubble.shape)
|
||||||
.background(bubble.background)
|
.background(bubble.background)
|
||||||
.height(IntrinsicSize.Max)
|
.height(IntrinsicSize.Max)
|
||||||
.combinedClickable(onLongClick = onLongClick, onClick = {}),
|
.combinedClickable(onLongClick = onLongClick, onClick = onItemClick),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@ -233,16 +247,50 @@ private fun Footer(event: BubbleModel.Event, bubble: BubbleMeta, status: @Compos
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TextContent(bubble: BubbleMeta, text: String) {
|
private fun TextContent(bubble: BubbleMeta, text: RichText) {
|
||||||
Text(
|
val annotatedText = text.toAnnotatedText()
|
||||||
text = text,
|
val uriHandler = LocalUriHandler.current
|
||||||
color = bubble.textColor(),
|
ClickableText(
|
||||||
fontSize = 15.sp,
|
text = annotatedText,
|
||||||
|
style = TextStyle(color = bubble.textColor(), fontSize = 15.sp, textAlign = TextAlign.Start),
|
||||||
modifier = Modifier.wrapContentSize(),
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
val nameStyle = SpanStyle(
|
||||||
|
color = Color(0xff64B5F6),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
is RichText.Part.Person -> withStyle(nameStyle) {
|
||||||
|
append("@${it.displayName.substringBefore(':').removePrefix("@")}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AuthorName(event: BubbleModel.Event, bubble: BubbleMeta) {
|
private fun AuthorName(event: BubbleModel.Event, bubble: BubbleMeta) {
|
||||||
Text(
|
Text(
|
||||||
@ -256,4 +304,4 @@ private fun AuthorName(event: BubbleModel.Event, bubble: BubbleMeta) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun BubbleMeta.textColor(): Color {
|
private fun BubbleMeta.textColor(): Color {
|
||||||
return if (this.isSelf) SmallTalkTheme.extendedColors.onSelfBubble else SmallTalkTheme.extendedColors.onOthersBubble
|
return if (this.isSelf) SmallTalkTheme.extendedColors.onSelfBubble else SmallTalkTheme.extendedColors.onOthersBubble
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.ArrowBack
|
|||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
|
||||||
@ -16,13 +17,14 @@ fun Toolbar(
|
|||||||
onNavigate: (() -> Unit)? = null,
|
onNavigate: (() -> Unit)? = null,
|
||||||
title: String? = null,
|
title: String? = null,
|
||||||
offset: (Density.() -> IntOffset)? = null,
|
offset: (Density.() -> IntOffset)? = null,
|
||||||
|
color: Color = MaterialTheme.colorScheme.background,
|
||||||
actions: @Composable RowScope.() -> Unit = {}
|
actions: @Composable RowScope.() -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val navigationIcon = foo(onNavigate)
|
val navigationIcon = foo(onNavigate)
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
modifier = offset?.let { Modifier.offset(it) } ?: Modifier,
|
modifier = offset?.let { Modifier.offset(it) } ?: Modifier,
|
||||||
colors = TopAppBarDefaults.smallTopAppBarColors(
|
colors = TopAppBarDefaults.smallTopAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.background
|
containerColor = color,
|
||||||
),
|
),
|
||||||
navigationIcon = navigationIcon,
|
navigationIcon = navigationIcon,
|
||||||
title = title?.let { { Text(it, maxLines = 2) } } ?: {},
|
title = title?.let { { Text(it, maxLines = 2) } } ?: {},
|
||||||
|
@ -13,7 +13,10 @@ import app.dapk.st.matrix.sync.RoomStore
|
|||||||
import com.squareup.sqldelight.runtime.coroutines.asFlow
|
import com.squareup.sqldelight.runtime.coroutines.asFlow
|
||||||
import com.squareup.sqldelight.runtime.coroutines.mapToList
|
import com.squareup.sqldelight.runtime.coroutines.mapToList
|
||||||
import com.squareup.sqldelight.runtime.coroutines.mapToOneNotNull
|
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
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
private val json = Json
|
private val json = Json
|
||||||
|
@ -3,24 +3,44 @@ package app.dapk.st.home
|
|||||||
import app.dapk.st.core.BuildMeta
|
import app.dapk.st.core.BuildMeta
|
||||||
import app.dapk.st.domain.ApplicationPreferences
|
import app.dapk.st.domain.ApplicationPreferences
|
||||||
import app.dapk.st.domain.ApplicationVersion
|
import app.dapk.st.domain.ApplicationVersion
|
||||||
|
import kotlinx.coroutines.CancellableContinuation
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
class BetaVersionUpgradeUseCase(
|
class BetaVersionUpgradeUseCase(
|
||||||
private val applicationPreferences: ApplicationPreferences,
|
private val applicationPreferences: ApplicationPreferences,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private var _continuation: CancellableContinuation<Unit>? = null
|
||||||
|
|
||||||
fun hasVersionChanged(): Boolean {
|
fun hasVersionChanged(): Boolean {
|
||||||
return runBlocking {
|
return runBlocking { hasChangedVersion() }
|
||||||
val previousVersion = applicationPreferences.readVersion()?.value
|
}
|
||||||
val currentVersion = buildMeta.versionCode
|
|
||||||
when (previousVersion) {
|
private suspend fun hasChangedVersion(): Boolean {
|
||||||
null -> false
|
val previousVersion = applicationPreferences.readVersion()?.value
|
||||||
else -> currentVersion > previousVersion
|
val currentVersion = buildMeta.versionCode
|
||||||
}.also {
|
return when (previousVersion) {
|
||||||
applicationPreferences.setVersion(ApplicationVersion(currentVersion))
|
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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -1,6 +1,5 @@
|
|||||||
package app.dapk.st.home
|
package app.dapk.st.home
|
||||||
|
|
||||||
import app.dapk.st.core.BuildMeta
|
|
||||||
import app.dapk.st.core.ProvidableModule
|
import app.dapk.st.core.ProvidableModule
|
||||||
import app.dapk.st.directory.DirectoryViewModel
|
import app.dapk.st.directory.DirectoryViewModel
|
||||||
import app.dapk.st.domain.StoreModule
|
import app.dapk.st.domain.StoreModule
|
||||||
@ -11,7 +10,7 @@ import app.dapk.st.profile.ProfileViewModel
|
|||||||
class HomeModule(
|
class HomeModule(
|
||||||
private val chatEngine: ChatEngine,
|
private val chatEngine: ChatEngine,
|
||||||
private val storeModule: StoreModule,
|
private val storeModule: StoreModule,
|
||||||
private val buildMeta: BuildMeta,
|
val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
|
||||||
) : ProvidableModule {
|
) : ProvidableModule {
|
||||||
|
|
||||||
fun homeViewModel(directory: DirectoryViewModel, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel {
|
fun homeViewModel(directory: DirectoryViewModel, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel {
|
||||||
@ -22,10 +21,7 @@ class HomeModule(
|
|||||||
login,
|
login,
|
||||||
profileViewModel,
|
profileViewModel,
|
||||||
storeModule.cacheCleaner(),
|
storeModule.cacheCleaner(),
|
||||||
BetaVersionUpgradeUseCase(
|
betaVersionUpgradeUseCase,
|
||||||
storeModule.applicationStore(),
|
|
||||||
buildMeta,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,7 +12,6 @@ import app.dapk.st.profile.ProfileViewModel
|
|||||||
import app.dapk.st.viewmodel.DapkViewModel
|
import app.dapk.st.viewmodel.DapkViewModel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
@ -87,6 +86,7 @@ class HomeViewModel(
|
|||||||
fun clearCache() {
|
fun clearCache() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
cacheCleaner.cleanCache(removeCredentials = false)
|
cacheCleaner.cleanCache(removeCredentials = false)
|
||||||
|
betaVersionUpgradeUseCase.notifyUpgraded()
|
||||||
_events.emit(HomeEvent.Relaunch)
|
_events.emit(HomeEvent.Relaunch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package app.dapk.st.messenger
|
package app.dapk.st.messenger
|
||||||
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@ -8,6 +9,7 @@ import androidx.compose.foundation.Image
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.*
|
import androidx.compose.foundation.lazy.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
@ -24,7 +26,11 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
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.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@ -42,6 +48,7 @@ import app.dapk.st.engine.MessageMeta
|
|||||||
import app.dapk.st.engine.MessengerState
|
import app.dapk.st.engine.MessengerState
|
||||||
import app.dapk.st.engine.RoomEvent
|
import app.dapk.st.engine.RoomEvent
|
||||||
import app.dapk.st.engine.RoomState
|
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.RoomId
|
||||||
import app.dapk.st.matrix.common.UserId
|
import app.dapk.st.matrix.common.UserId
|
||||||
import app.dapk.st.messenger.gallery.ImageGalleryActivityPayload
|
import app.dapk.st.messenger.gallery.ImageGalleryActivityPayload
|
||||||
@ -50,6 +57,8 @@ import app.dapk.st.navigator.Navigator
|
|||||||
import coil.compose.rememberAsyncImagePainter
|
import coil.compose.rememberAsyncImagePainter
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MessengerScreen(
|
internal fun MessengerScreen(
|
||||||
@ -75,7 +84,8 @@ internal fun MessengerScreen(
|
|||||||
val messageActions = MessageActions(
|
val messageActions = MessageActions(
|
||||||
onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) },
|
onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) },
|
||||||
onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) },
|
onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) },
|
||||||
onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) }
|
onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) },
|
||||||
|
onImageClick = { viewModel.selectImage(it) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
@ -84,6 +94,7 @@ internal fun MessengerScreen(
|
|||||||
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
|
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
|
||||||
// }
|
// }
|
||||||
})
|
})
|
||||||
|
|
||||||
when (state.composerState) {
|
when (state.composerState) {
|
||||||
is ComposerState.Text -> {
|
is ComposerState.Text -> {
|
||||||
Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) })
|
Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) })
|
||||||
@ -105,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
|
@Composable
|
||||||
@ -179,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(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -200,7 +296,7 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio
|
|||||||
onReply = { messageActions.onReply(item) },
|
onReply = { messageActions.onReply(item) },
|
||||||
) {
|
) {
|
||||||
val status = @Composable { SendStatus(item) }
|
val status = @Composable { SendStatus(item) }
|
||||||
MessageBubble(this, item.toModel(), status, onLongClick = messageActions.onLongClick)
|
MessageBubble(this, item.toModel(), status, bubbleActions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -210,7 +306,7 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio
|
|||||||
private fun RoomEvent.toModel(): BubbleModel {
|
private fun RoomEvent.toModel(): BubbleModel {
|
||||||
val event = BubbleModel.Event(this.author.id.value, this.author.displayName ?: this.author.id.value, this.edited, this.time)
|
val event = BubbleModel.Event(this.author.id.value, this.author.displayName ?: this.author.id.value, this.edited, this.time)
|
||||||
return when (this) {
|
return 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.Encrypted -> BubbleModel.Encrypted(event)
|
||||||
is RoomEvent.Image -> {
|
is RoomEvent.Image -> {
|
||||||
val imageRequest = LocalImageRequestFactory.current
|
val imageRequest = LocalImageRequestFactory.current
|
||||||
@ -227,6 +323,19 @@ private fun RoomEvent.toModel(): BubbleModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
is RichText.Part.Person -> app.dapk.st.core.RichText.Part.Person(it.userId.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SendStatus(message: RoomEvent) {
|
private fun SendStatus(message: RoomEvent) {
|
||||||
when (val meta = message.meta) {
|
when (val meta = message.meta) {
|
||||||
@ -269,7 +378,13 @@ private fun SendStatus(message: RoomEvent) {
|
|||||||
|
|
||||||
@OptIn(ExperimentalAnimationApi::class)
|
@OptIn(ExperimentalAnimationApi::class)
|
||||||
@Composable
|
@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(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -320,7 +435,7 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = it.content,
|
text = it.content.toApp().toAnnotatedText(),
|
||||||
color = SmallTalkTheme.extendedColors.onOthersBubble,
|
color = SmallTalkTheme.extendedColors.onOthersBubble,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
@ -352,6 +467,7 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un
|
|||||||
modifier = Modifier.clickable { onAttach() }.wrapContentWidth().align(Alignment.Bottom),
|
modifier = Modifier.clickable { onAttach() }.wrapContentWidth().align(Alignment.Bottom),
|
||||||
imageVector = Icons.Filled.Image,
|
imageVector = Icons.Filled.Image,
|
||||||
contentDescription = "",
|
contentDescription = "",
|
||||||
|
tint = SmallTalkTheme.extendedColors.onOthersBubble.copy(alpha = 0.5f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -433,4 +549,5 @@ class MessageActions(
|
|||||||
val onReply: (RoomEvent) -> Unit,
|
val onReply: (RoomEvent) -> Unit,
|
||||||
val onDismiss: () -> Unit,
|
val onDismiss: () -> Unit,
|
||||||
val onLongClick: (BubbleModel) -> Unit,
|
val onLongClick: (BubbleModel) -> Unit,
|
||||||
|
val onImageClick: (BubbleModel.Image) -> Unit,
|
||||||
)
|
)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package app.dapk.st.messenger
|
package app.dapk.st.messenger
|
||||||
|
|
||||||
import app.dapk.st.core.Lce
|
import app.dapk.st.core.Lce
|
||||||
|
import app.dapk.st.design.components.BubbleModel
|
||||||
import app.dapk.st.engine.MessengerState
|
import app.dapk.st.engine.MessengerState
|
||||||
import app.dapk.st.engine.RoomEvent
|
import app.dapk.st.engine.RoomEvent
|
||||||
import app.dapk.st.matrix.common.RoomId
|
import app.dapk.st.matrix.common.RoomId
|
||||||
@ -10,6 +11,11 @@ data class MessengerScreenState(
|
|||||||
val roomId: RoomId?,
|
val roomId: RoomId?,
|
||||||
val roomState: Lce<MessengerState>,
|
val roomState: Lce<MessengerState>,
|
||||||
val composerState: ComposerState,
|
val composerState: ComposerState,
|
||||||
|
val viewerState: ViewerState?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ViewerState(
|
||||||
|
val event: BubbleModel.Image,
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface MessengerEvent {
|
sealed interface MessengerEvent {
|
||||||
|
@ -4,6 +4,7 @@ import android.os.Build
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.dapk.st.core.DeviceMeta
|
import app.dapk.st.core.DeviceMeta
|
||||||
import app.dapk.st.core.Lce
|
import app.dapk.st.core.Lce
|
||||||
|
import app.dapk.st.core.asString
|
||||||
import app.dapk.st.core.extensions.takeIfContent
|
import app.dapk.st.core.extensions.takeIfContent
|
||||||
import app.dapk.st.design.components.BubbleModel
|
import app.dapk.st.design.components.BubbleModel
|
||||||
import app.dapk.st.domain.application.message.MessageOptionsStore
|
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.RoomEvent
|
||||||
import app.dapk.st.engine.SendMessage
|
import app.dapk.st.engine.SendMessage
|
||||||
import app.dapk.st.matrix.common.RoomId
|
import app.dapk.st.matrix.common.RoomId
|
||||||
|
import app.dapk.st.matrix.common.asString
|
||||||
import app.dapk.st.navigator.MessageAttachment
|
import app.dapk.st.navigator.MessageAttachment
|
||||||
import app.dapk.st.viewmodel.DapkViewModel
|
import app.dapk.st.viewmodel.DapkViewModel
|
||||||
import app.dapk.st.viewmodel.MutableStateFactory
|
import app.dapk.st.viewmodel.MutableStateFactory
|
||||||
@ -30,7 +32,8 @@ internal class MessengerViewModel(
|
|||||||
initialState = MessengerScreenState(
|
initialState = MessengerScreenState(
|
||||||
roomId = null,
|
roomId = null,
|
||||||
roomState = Lce.Loading(),
|
roomState = Lce.Loading(),
|
||||||
composerState = ComposerState.Text(value = "", reply = null)
|
composerState = ComposerState.Text(value = "", reply = null),
|
||||||
|
viewerState = null,
|
||||||
),
|
),
|
||||||
factory = factory,
|
factory = factory,
|
||||||
) {
|
) {
|
||||||
@ -116,7 +119,7 @@ internal class MessengerViewModel(
|
|||||||
originalMessage = when (it) {
|
originalMessage = when (it) {
|
||||||
is RoomEvent.Image -> TODO()
|
is RoomEvent.Image -> TODO()
|
||||||
is RoomEvent.Reply -> TODO()
|
is RoomEvent.Reply -> TODO()
|
||||||
is RoomEvent.Message -> it.content
|
is RoomEvent.Message -> it.content.asString()
|
||||||
is RoomEvent.Encrypted -> error("Should never happen")
|
is RoomEvent.Encrypted -> error("Should never happen")
|
||||||
},
|
},
|
||||||
eventId = it.eventId,
|
eventId = it.eventId,
|
||||||
@ -155,13 +158,25 @@ 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) {
|
private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) {
|
||||||
is BubbleModel.Encrypted -> CopyableResult.NothingToCopy
|
is BubbleModel.Encrypted -> CopyableResult.NothingToCopy
|
||||||
is BubbleModel.Image -> CopyableResult.NothingToCopy
|
is BubbleModel.Image -> CopyableResult.NothingToCopy
|
||||||
is BubbleModel.Reply -> this.reply.findCopyableContent()
|
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 {
|
private sealed interface CopyableResult {
|
||||||
|
@ -48,7 +48,8 @@ class MessengerViewModelTest {
|
|||||||
MessengerScreenState(
|
MessengerScreenState(
|
||||||
roomId = null,
|
roomId = null,
|
||||||
roomState = Lce.Loading(),
|
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(
|
fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState(
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
roomState = Lce.Content(roomState),
|
roomState = Lce.Content(roomState),
|
||||||
composerState = ComposerState.Text(value = messageContent ?: "", reply = null)
|
composerState = ComposerState.Text(value = messageContent ?: "", reply = null),
|
||||||
|
viewerState = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class FakeCopyToClipboard {
|
class FakeCopyToClipboard {
|
||||||
|
@ -2,6 +2,7 @@ package app.dapk.st.notifications
|
|||||||
|
|
||||||
import app.dapk.st.engine.RoomEvent
|
import app.dapk.st.engine.RoomEvent
|
||||||
import app.dapk.st.matrix.common.RoomMember
|
import app.dapk.st.matrix.common.RoomMember
|
||||||
|
import app.dapk.st.matrix.common.asString
|
||||||
|
|
||||||
class RoomEventsToNotifiableMapper {
|
class RoomEventsToNotifiableMapper {
|
||||||
|
|
||||||
@ -11,7 +12,7 @@ class RoomEventsToNotifiableMapper {
|
|||||||
|
|
||||||
private fun RoomEvent.toNotifiableContent(): String = when (this) {
|
private fun RoomEvent.toNotifiableContent(): String = when (this) {
|
||||||
is RoomEvent.Image -> "\uD83D\uDCF7"
|
is RoomEvent.Image -> "\uD83D\uDCF7"
|
||||||
is RoomEvent.Message -> this.content
|
is RoomEvent.Message -> this.content.asString()
|
||||||
is RoomEvent.Reply -> this.message.toNotifiableContent()
|
is RoomEvent.Reply -> this.message.toNotifiableContent()
|
||||||
is RoomEvent.Encrypted -> "Encrypted message"
|
is RoomEvent.Encrypted -> "Encrypted message"
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
package app.dapk.st.notifications
|
package app.dapk.st.notifications
|
||||||
|
|
||||||
|
import app.dapk.st.matrix.common.RichText
|
||||||
|
import app.dapk.st.matrix.common.asString
|
||||||
import fixture.aRoomImageMessageEvent
|
import fixture.aRoomImageMessageEvent
|
||||||
import fixture.aRoomMessageEvent
|
import fixture.aRoomMessageEvent
|
||||||
import fixture.aRoomReplyMessageEvent
|
import fixture.aRoomReplyMessageEvent
|
||||||
@ -18,7 +20,7 @@ class RoomEventsToNotifiableMapperTest {
|
|||||||
|
|
||||||
result shouldBeEqualTo listOf(
|
result shouldBeEqualTo listOf(
|
||||||
Notifiable(
|
Notifiable(
|
||||||
content = event.content,
|
content = event.content.asString(),
|
||||||
utcTimestamp = event.utcTimestamp,
|
utcTimestamp = event.utcTimestamp,
|
||||||
author = event.author
|
author = event.author
|
||||||
)
|
)
|
||||||
@ -42,14 +44,14 @@ class RoomEventsToNotifiableMapperTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `given reply event with message, when mapping, then uses message for content`() {
|
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 event = aRoomReplyMessageEvent(reply, replyingTo = aRoomImageMessageEvent(utcTimestamp = -1))
|
||||||
|
|
||||||
val result = mapper.map(listOf(event))
|
val result = mapper.map(listOf(event))
|
||||||
|
|
||||||
result shouldBeEqualTo listOf(
|
result shouldBeEqualTo listOf(
|
||||||
Notifiable(
|
Notifiable(
|
||||||
content = reply.content,
|
content = reply.content.asString(),
|
||||||
utcTimestamp = event.utcTimestamp,
|
utcTimestamp = event.utcTimestamp,
|
||||||
author = event.author
|
author = event.author
|
||||||
)
|
)
|
||||||
|
@ -1,9 +1,6 @@
|
|||||||
package app.dapk.st.engine
|
package app.dapk.st.engine
|
||||||
|
|
||||||
import app.dapk.st.matrix.common.CredentialsStore
|
import app.dapk.st.matrix.common.*
|
||||||
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.message.MessageService
|
import app.dapk.st.matrix.message.MessageService
|
||||||
import app.dapk.st.matrix.room.RoomService
|
import app.dapk.st.matrix.room.RoomService
|
||||||
import app.dapk.st.matrix.sync.RoomStore
|
import app.dapk.st.matrix.sync.RoomStore
|
||||||
@ -68,7 +65,7 @@ internal class DirectoryUseCase(
|
|||||||
this.copy(
|
this.copy(
|
||||||
lastMessage = RoomOverview.LastMessage(
|
lastMessage = RoomOverview.LastMessage(
|
||||||
content = when (val message = latestEcho.message) {
|
content = when (val message = latestEcho.message) {
|
||||||
is MessageService.Message.TextMessage -> message.content.body
|
is MessageService.Message.TextMessage -> message.content.body.asString()
|
||||||
is MessageService.Message.ImageMessage -> "\uD83D\uDCF7"
|
is MessageService.Message.ImageMessage -> "\uD83D\uDCF7"
|
||||||
},
|
},
|
||||||
utcTimestamp = latestEcho.timestampUtc,
|
utcTimestamp = latestEcho.timestampUtc,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package app.dapk.st.engine
|
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.MessageService
|
||||||
import app.dapk.st.matrix.message.internal.ImageContentReader
|
import app.dapk.st.matrix.message.internal.ImageContentReader
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
@ -41,7 +42,7 @@ internal class SendMessageUseCase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createTextMessage(message: SendMessage.TextMessage, room: RoomOverview) = MessageService.Message.TextMessage(
|
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,
|
roomId = room.roomId,
|
||||||
sendEncrypted = room.isEncrypted,
|
sendEncrypted = room.isEncrypted,
|
||||||
localId = localIdFactory.create(),
|
localId = localIdFactory.create(),
|
||||||
@ -49,7 +50,7 @@ internal class SendMessageUseCase(
|
|||||||
reply = message.reply?.let {
|
reply = message.reply?.let {
|
||||||
MessageService.Message.TextMessage.Reply(
|
MessageService.Message.TextMessage.Reply(
|
||||||
author = it.author,
|
author = it.author,
|
||||||
originalMessage = it.originalMessage,
|
originalMessage = RichText.of(it.originalMessage),
|
||||||
replyContent = message.content,
|
replyContent = message.content,
|
||||||
eventId = it.eventId,
|
eventId = it.eventId,
|
||||||
timestampUtc = it.timestampUtc,
|
timestampUtc = it.timestampUtc,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package app.dapk.st.engine
|
package app.dapk.st.engine
|
||||||
|
|
||||||
import app.dapk.st.matrix.common.EventId
|
import app.dapk.st.matrix.common.EventId
|
||||||
|
import app.dapk.st.matrix.common.RichText
|
||||||
import app.dapk.st.matrix.message.MessageService
|
import app.dapk.st.matrix.message.MessageService
|
||||||
import fixture.*
|
import fixture.*
|
||||||
import org.amshove.kluent.shouldBeEqualTo
|
import org.amshove.kluent.shouldBeEqualTo
|
||||||
@ -54,7 +55,7 @@ class MergeWithLocalEchosUseCaseTest {
|
|||||||
|
|
||||||
private fun createLocalEcho(eventId: EventId, body: String, state: MessageService.LocalEcho.State) = aLocalEcho(
|
private fun createLocalEcho(eventId: EventId, body: String, state: MessageService.LocalEcho.State) = aLocalEcho(
|
||||||
eventId,
|
eventId,
|
||||||
aTextMessage(aTextContent(body)),
|
aTextMessage(aTextContent(RichText.of(body))),
|
||||||
state,
|
state,
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -1,5 +1,6 @@
|
|||||||
package app.dapk.st.engine
|
package app.dapk.st.engine
|
||||||
|
|
||||||
|
import app.dapk.st.matrix.common.RichText
|
||||||
import fake.FakeRoomStore
|
import fake.FakeRoomStore
|
||||||
import fixture.NotificationDiffFixtures.aNotificationDiff
|
import fixture.NotificationDiffFixtures.aNotificationDiff
|
||||||
import fixture.aMatrixRoomMessageEvent
|
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
|
import app.dapk.st.matrix.sync.RoomOverview as MatrixRoomOverview
|
||||||
|
|
||||||
private val NO_UNREADS = emptyMap<MatrixRoomOverview, List<MatrixRoomEvent>>()
|
private val NO_UNREADS = emptyMap<MatrixRoomOverview, List<MatrixRoomEvent>>()
|
||||||
private val A_MESSAGE = aMatrixRoomMessageEvent(eventId = anEventId("1"), content = "hello", utcTimestamp = 1000)
|
private val A_MESSAGE = aMatrixRoomMessageEvent(eventId = anEventId("1"), content = RichText.of("hello"), utcTimestamp = 1000)
|
||||||
private val A_MESSAGE_2 = aMatrixRoomMessageEvent(eventId = anEventId("2"), content = "world", utcTimestamp = 2000)
|
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 = aMatrixRoomOverview(roomId = aRoomId("1"))
|
||||||
private val A_ROOM_OVERVIEW_2 = aMatrixRoomOverview(roomId = aRoomId("2"))
|
private val A_ROOM_OVERVIEW_2 = aMatrixRoomOverview(roomId = aRoomId("2"))
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package app.dapk.st.engine
|
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.RoomId
|
||||||
import app.dapk.st.matrix.common.RoomMember
|
import app.dapk.st.matrix.common.RoomMember
|
||||||
import app.dapk.st.matrix.common.UserId
|
import app.dapk.st.matrix.common.UserId
|
||||||
@ -24,7 +25,7 @@ import test.delegateReturn
|
|||||||
private val A_ROOM_ID = aRoomId()
|
private val A_ROOM_ID = aRoomId()
|
||||||
private val AN_USER_ID = aUserId()
|
private val AN_USER_ID = aUserId()
|
||||||
private val A_ROOM_STATE = aMatrixRoomState()
|
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_LOCAL_ECHOS_LIST = listOf(aLocalEcho())
|
||||||
private val A_ROOM_MEMBER = aRoomMember()
|
private val A_ROOM_MEMBER = aRoomMember()
|
||||||
|
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
package app.dapk.st.matrix.common
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RichText(@SerialName("parts") val parts: List<Part>) {
|
||||||
|
@Serializable
|
||||||
|
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
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Person(@SerialName("user_id") val userId: UserId, @SerialName("display_name") val displayName: String) : Part
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(text: String) = RichText(listOf(RichText.Part.Normal(text)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.userId.value
|
||||||
|
}
|
||||||
|
}
|
@ -44,7 +44,7 @@ interface MessageService : MatrixService {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class Reply(
|
data class Reply(
|
||||||
val author: RoomMember,
|
val author: RoomMember,
|
||||||
val originalMessage: String,
|
val originalMessage: RichText,
|
||||||
val replyContent: String,
|
val replyContent: String,
|
||||||
val eventId: EventId,
|
val eventId: EventId,
|
||||||
val timestampUtc: Long,
|
val timestampUtc: Long,
|
||||||
@ -65,7 +65,7 @@ interface MessageService : MatrixService {
|
|||||||
sealed class Content {
|
sealed class Content {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TextContent(
|
data class TextContent(
|
||||||
@SerialName("body") val body: String,
|
@SerialName("body") val body: RichText,
|
||||||
@SerialName("msgtype") val type: String = MessageType.TEXT.value,
|
@SerialName("msgtype") val type: String = MessageType.TEXT.value,
|
||||||
) : Content()
|
) : Content()
|
||||||
|
|
||||||
|
@ -60,7 +60,6 @@ internal class SendMessageUseCase(
|
|||||||
|
|
||||||
private suspend fun imageMessageRequest(message: Message.ImageMessage): HttpRequest<ApiSendResponse> {
|
private suspend fun imageMessageRequest(message: Message.ImageMessage): HttpRequest<ApiSendResponse> {
|
||||||
val imageMeta = message.content.meta
|
val imageMeta = message.content.meta
|
||||||
|
|
||||||
return when (message.sendEncrypted) {
|
return when (message.sendEncrypted) {
|
||||||
true -> {
|
true -> {
|
||||||
val result = mediaEncrypter.encrypt(imageContentReader.inputStream(message.content.uri))
|
val result = mediaEncrypter.encrypt(imageContentReader.inputStream(message.content.uri))
|
||||||
@ -153,13 +152,13 @@ class ApiMessageMapper {
|
|||||||
|
|
||||||
fun Message.TextMessage.toContents(reply: Message.TextMessage.Reply?) = when (reply) {
|
fun Message.TextMessage.toContents(reply: Message.TextMessage.Reply?) = when (reply) {
|
||||||
null -> ApiMessage.TextMessage.TextContent(
|
null -> ApiMessage.TextMessage.TextContent(
|
||||||
body = this.content.body,
|
body = this.content.body.asString(),
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> ApiMessage.TextMessage.TextContent(
|
else -> ApiMessage.TextMessage.TextContent(
|
||||||
body = buildReplyFallback(reply.originalMessage, reply.author.id, reply.replyContent),
|
body = buildReplyFallback(reply.originalMessage.asString(), reply.author.id, reply.replyContent),
|
||||||
relatesTo = ApiMessage.RelatesTo(ApiMessage.RelatesTo.InReplyTo(reply.eventId)),
|
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.asString(), reply.replyContent, this.roomId, reply.eventId),
|
||||||
format = "org.matrix.custom.html"
|
format = "org.matrix.custom.html"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package fixture
|
package fixture
|
||||||
|
|
||||||
import app.dapk.st.matrix.common.MessageType
|
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.common.RoomId
|
||||||
import app.dapk.st.matrix.message.MessageService
|
import app.dapk.st.matrix.message.MessageService
|
||||||
|
|
||||||
@ -13,6 +14,6 @@ fun aTextMessage(
|
|||||||
) = MessageService.Message.TextMessage(content, sendEncrypted, roomId, localId, timestampUtc)
|
) = MessageService.Message.TextMessage(content, sendEncrypted, roomId, localId, timestampUtc)
|
||||||
|
|
||||||
fun aTextContent(
|
fun aTextContent(
|
||||||
body: String = "text content body",
|
body: RichText = RichText.of("text content body"),
|
||||||
type: String = MessageType.TEXT.value,
|
type: String = MessageType.TEXT.value,
|
||||||
) = MessageService.Message.Content.TextContent(body, type)
|
) = MessageService.Message.Content.TextContent(body, type)
|
||||||
|
@ -45,7 +45,7 @@ sealed class RoomEvent {
|
|||||||
data class Message(
|
data class Message(
|
||||||
@SerialName("event_id") override val eventId: EventId,
|
@SerialName("event_id") override val eventId: EventId,
|
||||||
@SerialName("timestamp") override val utcTimestamp: Long,
|
@SerialName("timestamp") override val utcTimestamp: Long,
|
||||||
@SerialName("content") val content: String,
|
@SerialName("content") val content: RichText,
|
||||||
@SerialName("author") override val author: RoomMember,
|
@SerialName("author") override val author: RoomMember,
|
||||||
@SerialName("meta") override val meta: MessageMeta,
|
@SerialName("meta") override val meta: MessageMeta,
|
||||||
@SerialName("edited") val edited: Boolean = false,
|
@SerialName("edited") val edited: Boolean = false,
|
||||||
|
@ -8,6 +8,7 @@ import app.dapk.st.matrix.sync.internal.DefaultSyncService
|
|||||||
import app.dapk.st.matrix.sync.internal.request.*
|
import app.dapk.st.matrix.sync.internal.request.*
|
||||||
import app.dapk.st.matrix.sync.internal.room.MessageDecrypter
|
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.room.MissingMessageDecrypter
|
||||||
|
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@ -53,6 +54,7 @@ fun MatrixServiceInstaller.installSyncService(
|
|||||||
roomMembersService: ServiceDepFactory<RoomMembersService>,
|
roomMembersService: ServiceDepFactory<RoomMembersService>,
|
||||||
errorTracker: ErrorTracker,
|
errorTracker: ErrorTracker,
|
||||||
coroutineDispatchers: CoroutineDispatchers,
|
coroutineDispatchers: CoroutineDispatchers,
|
||||||
|
|
||||||
syncConfig: SyncConfig = SyncConfig(),
|
syncConfig: SyncConfig = SyncConfig(),
|
||||||
): InstallExtender<SyncService> {
|
): InstallExtender<SyncService> {
|
||||||
this.serializers {
|
this.serializers {
|
||||||
@ -96,6 +98,7 @@ fun MatrixServiceInstaller.installSyncService(
|
|||||||
errorTracker = errorTracker,
|
errorTracker = errorTracker,
|
||||||
coroutineDispatchers = coroutineDispatchers,
|
coroutineDispatchers = coroutineDispatchers,
|
||||||
syncConfig = syncConfig,
|
syncConfig = syncConfig,
|
||||||
|
richMessageParser = RichMessageParser()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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.SyncEventDecrypter
|
||||||
import app.dapk.st.matrix.sync.internal.room.SyncSideEffects
|
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.*
|
||||||
|
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
@ -41,13 +42,14 @@ internal class DefaultSyncService(
|
|||||||
errorTracker: ErrorTracker,
|
errorTracker: ErrorTracker,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
syncConfig: SyncConfig,
|
syncConfig: SyncConfig,
|
||||||
|
richMessageParser: RichMessageParser,
|
||||||
) : SyncService {
|
) : SyncService {
|
||||||
|
|
||||||
private val syncEventsFlow = MutableStateFlow<List<SyncService.SyncEvent>>(emptyList())
|
private val syncEventsFlow = MutableStateFlow<List<SyncService.SyncEvent>>(emptyList())
|
||||||
|
|
||||||
private val roomDataSource by lazy { RoomDataSource(roomStore, logger) }
|
private val roomDataSource by lazy { RoomDataSource(roomStore, logger) }
|
||||||
private val eventDecrypter by lazy { SyncEventDecrypter(messageDecrypter, json, 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 roomRefresher by lazy { RoomRefresher(roomDataSource, roomEventsDecrypter, logger) }
|
||||||
|
|
||||||
private val sync2 by lazy {
|
private val sync2 by lazy {
|
||||||
@ -57,7 +59,7 @@ internal class DefaultSyncService(
|
|||||||
roomMembersService,
|
roomMembersService,
|
||||||
roomDataSource,
|
roomDataSource,
|
||||||
TimelineEventsProcessor(
|
TimelineEventsProcessor(
|
||||||
RoomEventCreator(roomMembersService, errorTracker, RoomEventFactory(roomMembersService)),
|
RoomEventCreator(roomMembersService, errorTracker, RoomEventFactory(roomMembersService, richMessageParser), richMessageParser),
|
||||||
roomEventsDecrypter,
|
roomEventsDecrypter,
|
||||||
eventDecrypter,
|
eventDecrypter,
|
||||||
EventLookupUseCase(roomStore)
|
EventLookupUseCase(roomStore)
|
||||||
|
@ -6,10 +6,12 @@ import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
|
|||||||
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent.TimelineMessage.Content.Image
|
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent.TimelineMessage.Content.Image
|
||||||
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent.TimelineMessage.Content.Text
|
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.request.DecryptedContent
|
||||||
|
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
internal class RoomEventsDecrypter(
|
internal class RoomEventsDecrypter(
|
||||||
private val messageDecrypter: MessageDecrypter,
|
private val messageDecrypter: MessageDecrypter,
|
||||||
|
private val richMessageParser: RichMessageParser,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
private val logger: MatrixLogger,
|
private val logger: MatrixLogger,
|
||||||
) {
|
) {
|
||||||
@ -50,7 +52,7 @@ internal class RoomEventsDecrypter(
|
|||||||
meta = this.meta,
|
meta = this.meta,
|
||||||
edited = this.edited,
|
edited = this.edited,
|
||||||
redacted = this.redacted,
|
redacted = this.redacted,
|
||||||
content = content.body ?: ""
|
content = richMessageParser.parse(content.body ?: "")
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun RoomEvent.Encrypted.createImageEvent(content: Image, userCredentials: UserCredentials) = RoomEvent.Image(
|
private fun RoomEvent.Encrypted.createImageEvent(content: Image, userCredentials: UserCredentials) = RoomEvent.Image(
|
||||||
|
@ -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 {
|
private fun RoomState.replaceEvent(old: RoomEvent, new: RoomEvent): RoomState {
|
||||||
val updatedEvents = this.events.toMutableList().apply {
|
val updatedEvents = this.events.toMutableList().apply {
|
||||||
|
@ -12,6 +12,7 @@ import app.dapk.st.matrix.sync.RoomMembersService
|
|||||||
import app.dapk.st.matrix.sync.find
|
import app.dapk.st.matrix.sync.find
|
||||||
import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent
|
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.request.ApiTimelineEvent
|
||||||
|
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
|
||||||
|
|
||||||
private typealias Lookup = suspend (EventId) -> LookupResult
|
private typealias Lookup = suspend (EventId) -> LookupResult
|
||||||
|
|
||||||
@ -19,6 +20,7 @@ internal class RoomEventCreator(
|
|||||||
private val roomMembersService: RoomMembersService,
|
private val roomMembersService: RoomMembersService,
|
||||||
private val errorTracker: ErrorTracker,
|
private val errorTracker: ErrorTracker,
|
||||||
private val roomEventFactory: RoomEventFactory,
|
private val roomEventFactory: RoomEventFactory,
|
||||||
|
private val richMessageParser: RichMessageParser,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun ApiTimelineEvent.Encrypted.toRoomEvent(roomId: RoomId): RoomEvent? {
|
suspend fun ApiTimelineEvent.Encrypted.toRoomEvent(roomId: RoomId): RoomEvent? {
|
||||||
@ -44,7 +46,7 @@ internal class RoomEventCreator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun ApiTimelineEvent.TimelineMessage.toRoomEvent(userCredentials: UserCredentials, roomId: RoomId, lookup: Lookup): RoomEvent? {
|
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 +54,7 @@ internal class TimelineEventMapper(
|
|||||||
private val userCredentials: UserCredentials,
|
private val userCredentials: UserCredentials,
|
||||||
private val roomId: RoomId,
|
private val roomId: RoomId,
|
||||||
private val roomEventFactory: RoomEventFactory,
|
private val roomEventFactory: RoomEventFactory,
|
||||||
|
private val richMessageParser: RichMessageParser,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun mapToRoomEvent(event: ApiTimelineEvent.TimelineMessage, lookup: Lookup): RoomEvent? {
|
suspend fun mapToRoomEvent(event: ApiTimelineEvent.TimelineMessage, lookup: Lookup): RoomEvent? {
|
||||||
@ -138,7 +141,7 @@ internal class TimelineEventMapper(
|
|||||||
|
|
||||||
is ApiTimelineEvent.TimelineMessage.Content.Text -> original.toTextMessage(
|
is ApiTimelineEvent.TimelineMessage.Content.Text -> original.toTextMessage(
|
||||||
utcTimestamp = incomingEdit.utcTimestamp,
|
utcTimestamp = incomingEdit.utcTimestamp,
|
||||||
content = incomingEdit.asTextContent().body?.removePrefix(" * ")?.trim() ?: "redacted",
|
content = incomingEdit.asTextContent().let { it.formattedBody ?: it.body }?.removePrefix(" * ") ?: "redacted",
|
||||||
edited = true,
|
edited = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -148,7 +151,7 @@ internal class TimelineEventMapper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun RoomEvent.Message.edited(edit: ApiTimelineEvent.TimelineMessage) = this.copy(
|
private fun RoomEvent.Message.edited(edit: ApiTimelineEvent.TimelineMessage) = this.copy(
|
||||||
content = edit.asTextContent().body?.removePrefix(" * ")?.trim() ?: "redacted",
|
content = richMessageParser.parse(edit.asTextContent().let { it.formattedBody ?: it.body }?.removePrefix(" * ") ?: "redacted"),
|
||||||
utcTimestamp = edit.utcTimestamp,
|
utcTimestamp = edit.utcTimestamp,
|
||||||
edited = true,
|
edited = true,
|
||||||
)
|
)
|
||||||
@ -156,13 +159,17 @@ internal class TimelineEventMapper(
|
|||||||
private suspend fun RoomEventFactory.mapToRoomEvent(source: ApiTimelineEvent.TimelineMessage): RoomEvent {
|
private suspend fun RoomEventFactory.mapToRoomEvent(source: ApiTimelineEvent.TimelineMessage): RoomEvent {
|
||||||
return when (source.content) {
|
return when (source.content) {
|
||||||
is ApiTimelineEvent.TimelineMessage.Content.Image -> source.toImageMessage(userCredentials, roomId)
|
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 ?: "redacted"
|
||||||
|
)
|
||||||
|
|
||||||
ApiTimelineEvent.TimelineMessage.Content.Ignored -> throw IllegalStateException()
|
ApiTimelineEvent.TimelineMessage.Content.Ignored -> throw IllegalStateException()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage(
|
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,
|
edited: Boolean = false,
|
||||||
utcTimestamp: Long = this.utcTimestamp,
|
utcTimestamp: Long = this.utcTimestamp,
|
||||||
) = with(roomEventFactory) { toTextMessage(roomId, content, edited, utcTimestamp) }
|
) = with(roomEventFactory) { toTextMessage(roomId, content, edited, utcTimestamp) }
|
||||||
|
@ -6,21 +6,23 @@ import app.dapk.st.matrix.sync.RoomEvent
|
|||||||
import app.dapk.st.matrix.sync.RoomMembersService
|
import app.dapk.st.matrix.sync.RoomMembersService
|
||||||
import app.dapk.st.matrix.sync.find
|
import app.dapk.st.matrix.sync.find
|
||||||
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
|
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)
|
private val UNKNOWN_AUTHOR = RoomMember(id = UserId("unknown"), displayName = null, avatarUrl = null)
|
||||||
|
|
||||||
internal class RoomEventFactory(
|
internal class RoomEventFactory(
|
||||||
private val roomMembersService: RoomMembersService
|
private val roomMembersService: RoomMembersService,
|
||||||
|
private val richMessageParser: RichMessageParser,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage(
|
suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage(
|
||||||
roomId: RoomId,
|
roomId: RoomId,
|
||||||
content: String = this.asTextContent().formattedBody?.stripTags() ?: this.asTextContent().body ?: "redacted",
|
content: String,
|
||||||
edited: Boolean = false,
|
edited: Boolean = false,
|
||||||
utcTimestamp: Long = this.utcTimestamp,
|
utcTimestamp: Long = this.utcTimestamp,
|
||||||
) = RoomEvent.Message(
|
) = RoomEvent.Message(
|
||||||
eventId = this.id,
|
eventId = this.id,
|
||||||
content = content,
|
content = richMessageParser.parse(content),
|
||||||
author = roomMembersService.find(roomId, this.senderId) ?: UNKNOWN_AUTHOR,
|
author = roomMembersService.find(roomId, this.senderId) ?: UNKNOWN_AUTHOR,
|
||||||
utcTimestamp = utcTimestamp,
|
utcTimestamp = utcTimestamp,
|
||||||
meta = MessageMeta.FromServer,
|
meta = MessageMeta.FromServer,
|
||||||
@ -52,37 +54,3 @@ internal class RoomEventFactory(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun String.indexOfOrNull(string: String) = this.indexOf(string).takeIf { it != -1 }
|
|
||||||
|
|
||||||
fun String.stripTags() = this
|
|
||||||
.run {
|
|
||||||
this.indexOfOrNull("</mx-reply>")?.let {
|
|
||||||
this.substring(it + "</mx-reply>".length)
|
|
||||||
} ?: this
|
|
||||||
}
|
|
||||||
.trim()
|
|
||||||
.replaceLinks()
|
|
||||||
.removeTag("p")
|
|
||||||
.removeTag("em")
|
|
||||||
.removeTag("strong")
|
|
||||||
.removeTag("code")
|
|
||||||
.removeTag("pre")
|
|
||||||
.replace(""", "\"")
|
|
||||||
.replace("'", "'")
|
|
||||||
.replace("<br />", "\n")
|
|
||||||
.replace("<br/>", "\n")
|
|
||||||
|
|
||||||
private fun String.removeTag(name: String) = this.replace("<$name>", "").replace("/$name>", "")
|
|
||||||
|
|
||||||
private fun String.replaceLinks(): String {
|
|
||||||
return this.indexOfOrNull("<a href=")?.let { start ->
|
|
||||||
val openTagClose = indexOfOrNull("\">")!!
|
|
||||||
val end = indexOfOrNull("</a>")!!
|
|
||||||
val content = this.substring(openTagClose + "\">".length, end)
|
|
||||||
this.replaceRange(start, end + "</a>".length, content)
|
|
||||||
} ?: this
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ApiTimelineEvent.TimelineMessage.asTextContent() = this.content as ApiTimelineEvent.TimelineMessage.Content.Text
|
|
||||||
|
@ -1,9 +1,6 @@
|
|||||||
package app.dapk.st.matrix.sync.internal.sync
|
package app.dapk.st.matrix.sync.internal.sync
|
||||||
|
|
||||||
import app.dapk.st.matrix.common.AvatarUrl
|
import app.dapk.st.matrix.common.*
|
||||||
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.sync.*
|
import app.dapk.st.matrix.sync.*
|
||||||
import app.dapk.st.matrix.sync.internal.request.ApiSyncRoom
|
import app.dapk.st.matrix.sync.internal.request.ApiSyncRoom
|
||||||
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
|
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
|
||||||
@ -79,7 +76,7 @@ internal fun List<RoomEvent>.findLastMessage(): LastMessage? {
|
|||||||
|
|
||||||
private fun RoomEvent.toTextContent(): String = when (this) {
|
private fun RoomEvent.toTextContent(): String = when (this) {
|
||||||
is RoomEvent.Image -> "\uD83D\uDCF7"
|
is RoomEvent.Image -> "\uD83D\uDCF7"
|
||||||
is RoomEvent.Message -> this.content
|
is RoomEvent.Message -> this.content.asString()
|
||||||
is RoomEvent.Reply -> this.message.toTextContent()
|
is RoomEvent.Reply -> this.message.toTextContent()
|
||||||
is RoomEvent.Encrypted -> "Encrypted message"
|
is RoomEvent.Encrypted -> "Encrypted message"
|
||||||
}
|
}
|
@ -0,0 +1,248 @@
|
|||||||
|
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
|
||||||
|
private val SKIPPED_TAGS = setOf("mx-reply")
|
||||||
|
|
||||||
|
internal class HtmlParser {
|
||||||
|
|
||||||
|
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,
|
||||||
|
onInvalidTag = { builder.appendText(input[it].toString()) },
|
||||||
|
onTag = { tagOpen, tagClose ->
|
||||||
|
val (wholeTag, tagName) = parseTag(input, tagOpen, tagClose)
|
||||||
|
|
||||||
|
when {
|
||||||
|
tagName.startsWith('@') -> {
|
||||||
|
appendTextBeforeTag(searchIndex, tagOpen, builder, input)
|
||||||
|
builder.appendPerson(UserId(tagName), tagName)
|
||||||
|
tagClose.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
tagName == "br" -> {
|
||||||
|
appendTextBeforeTag(searchIndex, tagOpen, builder, input)
|
||||||
|
builder.appendNewline()
|
||||||
|
tagClose.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> parseTagWithContent(input, tagName, tagClose, searchIndex, tagOpen, wholeTag, builder, nestingLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseTagWithContent(
|
||||||
|
input: String,
|
||||||
|
tagName: String,
|
||||||
|
tagClose: Int,
|
||||||
|
searchIndex: Int,
|
||||||
|
tagOpen: Int,
|
||||||
|
wholeTag: String,
|
||||||
|
builder: PartBuilder,
|
||||||
|
nestingLevel: Int
|
||||||
|
): Int {
|
||||||
|
val exitTag = "</$tagName>"
|
||||||
|
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,
|
||||||
|
wholeTag: String,
|
||||||
|
builder: PartBuilder,
|
||||||
|
tagContent: String,
|
||||||
|
exitTagCloseIndex: Int,
|
||||||
|
nestingLevel: Int,
|
||||||
|
) = when (tagName) {
|
||||||
|
"a" -> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"b", "strong" -> {
|
||||||
|
builder.appendBold(tagContent)
|
||||||
|
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
|
||||||
|
iterateSearchIndex { searchIndex ->
|
||||||
|
lastIndex = searchIndex
|
||||||
|
parseHtmlTags(tagContent, searchIndex, builder, nestingLevel = nestingLevel + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < tagContent.length) {
|
||||||
|
builder.appendText(tagContent.substring(lastIndex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.appendNewline()
|
||||||
|
exitTagCloseIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
"ul", "ol" -> {
|
||||||
|
parseList(tagName, tagContent, builder)
|
||||||
|
exitTagCloseIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
"h1", "h2", "h3", "h4", "h5" -> {
|
||||||
|
builder.appendBold(tagContent.trim())
|
||||||
|
builder.appendNewline()
|
||||||
|
exitTagCloseIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
"i", "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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseList(parentTag: String, parentContent: String, builder: PartBuilder) {
|
||||||
|
var listIndex = 1
|
||||||
|
iterateSearchIndex { nextIndex ->
|
||||||
|
singleTagParser(parentContent, "li", nextIndex, builder) { wholeTag, tagContent ->
|
||||||
|
val content = when (parentTag) {
|
||||||
|
"ol" -> {
|
||||||
|
listIndex = wholeTag.findTagAttribute("value")?.toInt() ?: listIndex
|
||||||
|
"$listIndex. $tagContent".also { listIndex++ }
|
||||||
|
}
|
||||||
|
|
||||||
|
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, tagName) = parseTag(content, tagOpen, tagClose)
|
||||||
|
|
||||||
|
if (tagName == wantedTagName) {
|
||||||
|
val exitTag = "</$tagName>"
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTag(input: String, tagOpen: Int, tagClose: Int): Pair<String, String> {
|
||||||
|
val wholeTag = input.substring(tagOpen, tagClose + 1)
|
||||||
|
val tagName = wholeTag.substring(1, wholeTag.indexOfFirst { it == '>' || it == ' ' })
|
||||||
|
return wholeTag to tagName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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('\"')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
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 = mutableListOf<RichText.Part>()
|
||||||
|
|
||||||
|
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(): List<RichText.Part> {
|
||||||
|
flushNormalBuffer()
|
||||||
|
val last = parts.last()
|
||||||
|
if (last is RichText.Part.Normal) {
|
||||||
|
parts.removeLast()
|
||||||
|
val newContent = last.content.trimEnd()
|
||||||
|
if (newContent.isNotEmpty()) {
|
||||||
|
parts.add(last.copy(content = newContent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun PartBuilder.appendNewline() {
|
||||||
|
this.appendText("\n")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
|||||||
|
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()
|
||||||
|
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)
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
if (hasReachedEnd) END_SEARCH else max(firstResult, secondResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
internal fun iterateSearchIndex(action: (SearchIndex) -> SearchIndex): SearchIndex {
|
||||||
|
var nextIndex = 0
|
||||||
|
while (nextIndex != END_SEARCH) {
|
||||||
|
nextIndex = action(nextIndex)
|
||||||
|
}
|
||||||
|
return nextIndex
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
package app.dapk.st.matrix.sync.internal.sync.message
|
||||||
|
|
||||||
|
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)
|
||||||
|
return if (urlIndex == END_SEARCH) END_SEARCH else {
|
||||||
|
builder.appendTextBeforeTag(linkStartIndex, urlIndex, input)
|
||||||
|
|
||||||
|
val originalUrl = input.substring(urlIndex)
|
||||||
|
var index = 0
|
||||||
|
val maybeUrl = originalUrl.takeWhile {
|
||||||
|
it != '\n' && it != ' ' && !originalUrl.hasLookAhead(index++, "<br")
|
||||||
|
}
|
||||||
|
|
||||||
|
val urlEndIndex = maybeUrl.length + urlIndex
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun test(startingFrom: Int, input: String): Int {
|
||||||
|
return input.indexOf("http", startingFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun String.bestGuessStripTrailingUrlChar(): String {
|
||||||
|
val last = this.last()
|
||||||
|
return if (INVALID_TRAILING_CHARS.contains(last)) {
|
||||||
|
this.dropLast(1)
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
@ -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.EncryptedMessageContent
|
||||||
import app.dapk.st.matrix.common.JsonString
|
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.RoomEvent
|
||||||
import app.dapk.st.matrix.sync.internal.request.DecryptedContent
|
import app.dapk.st.matrix.sync.internal.request.DecryptedContent
|
||||||
|
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
|
||||||
import fake.FakeMatrixLogger
|
import fake.FakeMatrixLogger
|
||||||
import fake.FakeMessageDecrypter
|
import fake.FakeMessageDecrypter
|
||||||
import fixture.*
|
import fixture.*
|
||||||
@ -31,6 +33,7 @@ class RoomEventsDecrypterTest {
|
|||||||
|
|
||||||
private val roomEventsDecrypter = RoomEventsDecrypter(
|
private val roomEventsDecrypter = RoomEventsDecrypter(
|
||||||
fakeMessageDecrypter,
|
fakeMessageDecrypter,
|
||||||
|
RichMessageParser(),
|
||||||
Json,
|
Json,
|
||||||
FakeMatrixLogger(),
|
FakeMatrixLogger(),
|
||||||
)
|
)
|
||||||
@ -88,7 +91,7 @@ private fun RoomEvent.Encrypted.MegOlmV1.toModel() = EncryptedMessageContent.Meg
|
|||||||
private fun RoomEvent.Encrypted.toText(text: String) = RoomEvent.Message(
|
private fun RoomEvent.Encrypted.toText(text: String) = RoomEvent.Message(
|
||||||
this.eventId,
|
this.eventId,
|
||||||
this.utcTimestamp,
|
this.utcTimestamp,
|
||||||
content = text,
|
content = RichText.of(text),
|
||||||
this.author,
|
this.author,
|
||||||
this.meta,
|
this.meta,
|
||||||
this.edited,
|
this.edited,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package app.dapk.st.matrix.sync.internal.sync
|
package app.dapk.st.matrix.sync.internal.sync
|
||||||
|
|
||||||
|
import app.dapk.st.matrix.common.RichText
|
||||||
import fake.FakeRoomStore
|
import fake.FakeRoomStore
|
||||||
import fixture.aMatrixRoomMessageEvent
|
import fixture.aMatrixRoomMessageEvent
|
||||||
import fixture.anEventId
|
import fixture.anEventId
|
||||||
@ -11,8 +12,8 @@ import org.junit.Test
|
|||||||
|
|
||||||
private val AN_EVENT_ID = anEventId()
|
private val AN_EVENT_ID = anEventId()
|
||||||
private val A_TIMELINE_EVENT = anApiTimelineTextEvent(AN_EVENT_ID, content = aTimelineTextEventContent(body = "timeline event"))
|
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_ROOM_EVENT = aMatrixRoomMessageEvent(AN_EVENT_ID, content = RichText.of("previous room event"))
|
||||||
private val A_PERSISTED_EVENT = aMatrixRoomMessageEvent(AN_EVENT_ID, content = "persisted event")
|
private val A_PERSISTED_EVENT = aMatrixRoomMessageEvent(AN_EVENT_ID, content = RichText.of("persisted event"))
|
||||||
|
|
||||||
class EventLookupUseCaseTest {
|
class EventLookupUseCaseTest {
|
||||||
|
|
||||||
|
@ -0,0 +1,267 @@
|
|||||||
|
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
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RichMessageParserTest {
|
||||||
|
|
||||||
|
private val parser = RichMessageParser()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses plain text`() = runParserTest(
|
||||||
|
input = "Hello world!",
|
||||||
|
expected = RichText(listOf(Normal("Hello world!")))
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses p tags`() = runParserTest(
|
||||||
|
input = "<p>Hello world!</p><p>foo bar</p>after paragraph",
|
||||||
|
expected = RichText(listOf(Normal("Hello world!\nfoo bar\nafter paragraph")))
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses nesting within p tags`() = runParserTest(
|
||||||
|
input = "<p><b>Hello world!</b></p>",
|
||||||
|
expected = RichText(listOf(Bold("Hello world!")))
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `replaces quote entity`() = runParserTest(
|
||||||
|
input = "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(listOf(Normal("Hello world! foo's bar")))
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `replaces people`() = runParserTest(
|
||||||
|
input = "Hello <@my-name:a-domain.foo>!",
|
||||||
|
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 href="https://matrix.to/#/@a-name:foo.bar">a-name</a>: world""",
|
||||||
|
expected = RichText(listOf(Normal("Hello "), Person(aUserId("@a-name:foo.bar"), "@a-name"), Normal(" world")))
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses header tags`() = runParserTest(
|
||||||
|
Case(
|
||||||
|
input = "<h1>hello</h1>",
|
||||||
|
expected = RichText(listOf(Bold("hello")))
|
||||||
|
),
|
||||||
|
Case(
|
||||||
|
input = "<h1>hello</h1>text after title",
|
||||||
|
expected = RichText(listOf(Bold("hello"), Normal("\ntext after title")))
|
||||||
|
),
|
||||||
|
Case(
|
||||||
|
input = "<h2>hello</h2>",
|
||||||
|
expected = RichText(listOf(Bold("hello")))
|
||||||
|
),
|
||||||
|
Case(
|
||||||
|
input = "<h3>hello</h3>",
|
||||||
|
expected = RichText(listOf(Bold("hello")))
|
||||||
|
),
|
||||||
|
Case(
|
||||||
|
input = "<h1>1</h1>\n<h2>1</h2>\n<h3>1</h3>\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!<br />next line<br />another line",
|
||||||
|
expected = RichText(listOf(Normal("Hello world!\nnext line\nanother line")))
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses blockquote tags`() = runParserTest(
|
||||||
|
input = "<blockquote>\n<p><strong>hello</strong> <em>world</em></p>\n</blockquote>\n",
|
||||||
|
expected = RichText(listOf(Normal("> "), Bold("hello"), Normal(" "), Italic("world")))
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses lists`() = runParserTest(
|
||||||
|
Case(
|
||||||
|
input = "<ul><li>content in list item</li><li>another item in list</li></ul>",
|
||||||
|
expected = RichText(listOf(Normal("- content in list item\n- another item in list")))
|
||||||
|
),
|
||||||
|
Case(
|
||||||
|
input = "<ol><li>content in list item</li><li>another item in list</li></ol>",
|
||||||
|
expected = RichText(listOf(Normal("1. content in list item\n2. another item in list")))
|
||||||
|
),
|
||||||
|
Case(
|
||||||
|
input = """<ol><li value="5">content in list item</li><li>another item in list</li></ol>""",
|
||||||
|
expected = RichText(listOf(Normal("5. content in list item\n6. another item in list")))
|
||||||
|
),
|
||||||
|
Case(
|
||||||
|
input = """<ol><li value="3">content in list item</li><li>another item in list</li><li value="10">another change</li><li>without value</li></ol>""",
|
||||||
|
expected = RichText(listOf(Normal("3. content in list item\n4. another item in list\n10. another change\n11. without value")))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses urls`() = runParserTest(
|
||||||
|
Case(
|
||||||
|
input = "https://google.com",
|
||||||
|
expected = RichText(listOf(Link("https://google.com", "https://google.com")))
|
||||||
|
),
|
||||||
|
Case(
|
||||||
|
input = "https://google.com. 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(listOf(Normal("ending sentence with url "), Link("https://google.com", "https://google.com"), Normal(".")))
|
||||||
|
),
|
||||||
|
Case(
|
||||||
|
input = "https://google.com<br>html after url",
|
||||||
|
expected = RichText(listOf(Link("https://google.com", "https://google.com"), Normal("\nhtml after url")))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removes reply fallback`() = runParserTest(
|
||||||
|
input = """
|
||||||
|
<mx-reply>
|
||||||
|
<blockquote>
|
||||||
|
Original message
|
||||||
|
</blockquote>
|
||||||
|
</mx-reply>
|
||||||
|
Reply to message
|
||||||
|
""".trimIndent(),
|
||||||
|
expected = RichText(listOf(Normal("Reply to message")))
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removes text fallback`() = runParserTest(
|
||||||
|
input = """
|
||||||
|
> <@user:domain.foo> Original message
|
||||||
|
> Some more content
|
||||||
|
|
||||||
|
Reply to message
|
||||||
|
""".trimIndent(),
|
||||||
|
expected = RichText(listOf(Normal("Reply to message")))
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses styling text`() = runParserTest(
|
||||||
|
input = "<em>hello</em> <strong>world</strong>",
|
||||||
|
expected = RichText(listOf(Italic("hello"), Normal(" "), Bold("world")))
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses invalid tags text`() = runParserTest(
|
||||||
|
input = ">><foo> ><>> << more content",
|
||||||
|
expected = RichText(listOf(Normal(">><foo> ><>> << more content")))
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses strong tags`() = runParserTest(
|
||||||
|
Case(
|
||||||
|
input = """hello <strong>wor</strong>ld""",
|
||||||
|
expected = RichText(
|
||||||
|
listOf(
|
||||||
|
Normal("hello "),
|
||||||
|
Bold("wor"),
|
||||||
|
Normal("ld"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses em tags`() = runParserTest(
|
||||||
|
Case(
|
||||||
|
input = """hello <em>wor</em>ld""",
|
||||||
|
expected = RichText(
|
||||||
|
listOf(
|
||||||
|
Normal("hello "),
|
||||||
|
Italic("wor"),
|
||||||
|
Normal("ld"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Ignore // TODO
|
||||||
|
@Test
|
||||||
|
fun `parses nested tags`() = runParserTest(
|
||||||
|
Case(
|
||||||
|
input = """hello <b><i>wor<i/><b/>ld""",
|
||||||
|
expected = RichText(
|
||||||
|
listOf(
|
||||||
|
Normal("hello "),
|
||||||
|
BoldItalic("wor"),
|
||||||
|
Normal("ld"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Case(
|
||||||
|
input = """<a href="www.google.com"><a href="www.google.com">www.google.com<a/><a/>""",
|
||||||
|
expected = RichText(
|
||||||
|
listOf(
|
||||||
|
Link(url = "www.google.com", label = "www.google.com"),
|
||||||
|
Link(url = "www.bing.com", label = "www.bing.com"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses 'a' tags`() = runParserTest(
|
||||||
|
Case(
|
||||||
|
input = """hello world <a href="www.google.com">a link!</a> more content.""",
|
||||||
|
expected = RichText(
|
||||||
|
listOf(
|
||||||
|
Normal("hello world "),
|
||||||
|
Link(url = "www.google.com", label = "a link!"),
|
||||||
|
Normal(" more content."),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Case(
|
||||||
|
input = """<a href="www.google.com">www.google.com</a><a href="www.bing.com">www.bing.com</a>""",
|
||||||
|
expected = RichText(
|
||||||
|
listOf(
|
||||||
|
Link(url = "www.google.com", label = "www.google.com"),
|
||||||
|
Link(url = "www.bing.com", label = "www.bing.com"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun runParserTest(vararg cases: Case) {
|
||||||
|
val errors = mutableListOf<Throwable>()
|
||||||
|
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>) : Throwable() {
|
||||||
|
init {
|
||||||
|
inner.forEach { addSuppressed(it) }
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,12 @@
|
|||||||
package app.dapk.st.matrix.sync.internal.sync
|
package app.dapk.st.matrix.sync.internal.sync
|
||||||
|
|
||||||
import app.dapk.st.matrix.common.EventId
|
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.RoomEvent
|
||||||
import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent
|
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.request.ApiTimelineEvent
|
||||||
|
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
|
||||||
import fake.FakeErrorTracker
|
import fake.FakeErrorTracker
|
||||||
import fake.FakeRoomMembersService
|
import fake.FakeRoomMembersService
|
||||||
import fixture.*
|
import fixture.*
|
||||||
@ -15,11 +18,11 @@ import org.junit.Test
|
|||||||
private val A_ROOM_ID = aRoomId()
|
private val A_ROOM_ID = aRoomId()
|
||||||
private val A_SENDER = aRoomMember()
|
private val A_SENDER = aRoomMember()
|
||||||
private val EMPTY_LOOKUP = FakeLookup(LookupResult(apiTimelineEvent = null, roomEvent = null))
|
private val EMPTY_LOOKUP = FakeLookup(LookupResult(apiTimelineEvent = null, roomEvent = null))
|
||||||
private const val A_TEXT_EVENT_MESSAGE = "a text message"
|
private val A_TEXT_EVENT_MESSAGE = RichText.of("a text message")
|
||||||
private const val A_REPLY_EVENT_MESSAGE = "a reply to another message"
|
private val A_REPLY_EVENT_MESSAGE = RichText.of("a reply to another message")
|
||||||
private val A_TEXT_EVENT = anApiTimelineTextEvent(
|
private val A_TEXT_EVENT = anApiTimelineTextEvent(
|
||||||
senderId = A_SENDER.id,
|
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(
|
private val A_TEXT_EVENT_WITHOUT_CONTENT = anApiTimelineTextEvent(
|
||||||
senderId = A_SENDER.id,
|
senderId = A_SENDER.id,
|
||||||
@ -31,7 +34,11 @@ internal class RoomEventCreatorTest {
|
|||||||
|
|
||||||
private val fakeRoomMembersService = FakeRoomMembersService()
|
private val fakeRoomMembersService = FakeRoomMembersService()
|
||||||
|
|
||||||
private val roomEventCreator = RoomEventCreator(fakeRoomMembersService, FakeErrorTracker(), RoomEventFactory(fakeRoomMembersService))
|
private val richMessageParser = RichMessageParser()
|
||||||
|
private val roomEventCreator = RoomEventCreator(
|
||||||
|
fakeRoomMembersService, FakeErrorTracker(), RoomEventFactory(fakeRoomMembersService, richMessageParser),
|
||||||
|
richMessageParser
|
||||||
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `given Megolm encrypted event then maps to encrypted room message`() = runTest {
|
fun `given Megolm encrypted event then maps to encrypted room message`() = runTest {
|
||||||
@ -89,7 +96,7 @@ internal class RoomEventCreatorTest {
|
|||||||
result shouldBeEqualTo aMatrixRoomMessageEvent(
|
result shouldBeEqualTo aMatrixRoomMessageEvent(
|
||||||
eventId = A_TEXT_EVENT_WITHOUT_CONTENT.id,
|
eventId = A_TEXT_EVENT_WITHOUT_CONTENT.id,
|
||||||
utcTimestamp = A_TEXT_EVENT_WITHOUT_CONTENT.utcTimestamp,
|
utcTimestamp = A_TEXT_EVENT_WITHOUT_CONTENT.utcTimestamp,
|
||||||
content = "redacted",
|
content = RichText.of("redacted"),
|
||||||
author = A_SENDER,
|
author = A_SENDER,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -97,14 +104,14 @@ internal class RoomEventCreatorTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `given edited event with no relation then maps to new room message`() = runTest {
|
fun `given edited event with no relation then maps to new room message`() = runTest {
|
||||||
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
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) }
|
val result = with(roomEventCreator) { editEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) }
|
||||||
|
|
||||||
result shouldBeEqualTo aMatrixRoomMessageEvent(
|
result shouldBeEqualTo aMatrixRoomMessageEvent(
|
||||||
eventId = editEvent.id,
|
eventId = editEvent.id,
|
||||||
utcTimestamp = editEvent.utcTimestamp,
|
utcTimestamp = editEvent.utcTimestamp,
|
||||||
content = editEvent.asTextContent().body!!,
|
content = RichText.of(editEvent.asTextContent().body!!.trimStart()),
|
||||||
author = A_SENDER,
|
author = A_SENDER,
|
||||||
edited = true
|
edited = true
|
||||||
)
|
)
|
||||||
@ -114,7 +121,7 @@ internal class RoomEventCreatorTest {
|
|||||||
fun `given edited event which relates to a timeline event then updates existing message`() = runTest {
|
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)
|
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||||
val originalMessage = anApiTimelineTextEvent(utcTimestamp = 0)
|
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 lookup = givenLookup(originalMessage)
|
||||||
|
|
||||||
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||||
@ -132,7 +139,7 @@ internal class RoomEventCreatorTest {
|
|||||||
fun `given edited event which relates to a room event then updates existing message`() = runTest {
|
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)
|
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||||
val originalMessage = aMatrixRoomMessageEvent()
|
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 lookup = givenLookup(originalMessage)
|
||||||
|
|
||||||
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||||
@ -150,7 +157,7 @@ internal class RoomEventCreatorTest {
|
|||||||
fun `given edited event which relates to a room reply event then only updates message`() = runTest {
|
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)
|
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||||
val originalMessage = aRoomReplyMessageEvent(message = aMatrixRoomMessageEvent())
|
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 lookup = givenLookup(originalMessage)
|
||||||
|
|
||||||
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||||
@ -170,7 +177,7 @@ internal class RoomEventCreatorTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `given edited event is older than related known timeline event then ignores edit`() = runTest {
|
fun `given edited event is older than related known timeline event then ignores edit`() = runTest {
|
||||||
val originalMessage = anApiTimelineTextEvent(utcTimestamp = 1000)
|
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 lookup = givenLookup(originalMessage)
|
||||||
|
|
||||||
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||||
@ -181,7 +188,7 @@ internal class RoomEventCreatorTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `given edited event is older than related room event then ignores edit`() = runTest {
|
fun `given edited event is older than related room event then ignores edit`() = runTest {
|
||||||
val originalMessage = aMatrixRoomMessageEvent(utcTimestamp = 1000)
|
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 lookup = givenLookup(originalMessage)
|
||||||
|
|
||||||
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||||
@ -192,7 +199,7 @@ internal class RoomEventCreatorTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `given reply event with no relation then maps to new room message using the full body`() = runTest {
|
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)
|
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)
|
println(replyEvent.content)
|
||||||
val result = with(roomEventCreator) { replyEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) }
|
val result = with(roomEventCreator) { replyEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) }
|
||||||
@ -200,7 +207,7 @@ internal class RoomEventCreatorTest {
|
|||||||
result shouldBeEqualTo aMatrixRoomMessageEvent(
|
result shouldBeEqualTo aMatrixRoomMessageEvent(
|
||||||
eventId = replyEvent.id,
|
eventId = replyEvent.id,
|
||||||
utcTimestamp = replyEvent.utcTimestamp,
|
utcTimestamp = replyEvent.utcTimestamp,
|
||||||
content = replyEvent.asTextContent().body!!,
|
content = RichText.of(replyEvent.asTextContent().body!!),
|
||||||
author = A_SENDER,
|
author = A_SENDER,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -209,7 +216,7 @@ internal class RoomEventCreatorTest {
|
|||||||
fun `given reply event which relates to a timeline event then maps to reply`() = runTest {
|
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)
|
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||||
val originalMessage = anApiTimelineTextEvent(content = aTimelineTextEventContent(body = "message being replied to"))
|
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 lookup = givenLookup(originalMessage)
|
||||||
|
|
||||||
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||||
@ -218,7 +225,7 @@ internal class RoomEventCreatorTest {
|
|||||||
replyingTo = aMatrixRoomMessageEvent(
|
replyingTo = aMatrixRoomMessageEvent(
|
||||||
eventId = originalMessage.id,
|
eventId = originalMessage.id,
|
||||||
utcTimestamp = originalMessage.utcTimestamp,
|
utcTimestamp = originalMessage.utcTimestamp,
|
||||||
content = originalMessage.asTextContent().body!!,
|
content = RichText.of(originalMessage.asTextContent().body!!),
|
||||||
author = A_SENDER,
|
author = A_SENDER,
|
||||||
),
|
),
|
||||||
message = aMatrixRoomMessageEvent(
|
message = aMatrixRoomMessageEvent(
|
||||||
@ -234,7 +241,7 @@ internal class RoomEventCreatorTest {
|
|||||||
fun `given reply event which relates to a room event then maps to reply`() = runTest {
|
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)
|
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||||
val originalMessage = aMatrixRoomMessageEvent()
|
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 lookup = givenLookup(originalMessage)
|
||||||
|
|
||||||
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||||
@ -254,7 +261,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 {
|
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)
|
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||||
val originalMessage = aRoomReplyMessageEvent()
|
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 lookup = givenLookup(originalMessage)
|
||||||
|
|
||||||
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package app.dapk.st.matrix.sync.internal.sync
|
package app.dapk.st.matrix.sync.internal.sync
|
||||||
|
|
||||||
import app.dapk.st.matrix.common.RoomId
|
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.RoomEvent
|
||||||
import app.dapk.st.matrix.sync.RoomState
|
import app.dapk.st.matrix.sync.RoomState
|
||||||
import fake.FakeMatrixLogger
|
import fake.FakeMatrixLogger
|
||||||
@ -60,7 +61,7 @@ internal class RoomRefresherTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun RoomEvent.Message.asLastMessage() = aLastMessage(
|
private fun RoomEvent.Message.asLastMessage() = aLastMessage(
|
||||||
this.content,
|
this.content.asString(),
|
||||||
this.utcTimestamp,
|
this.utcTimestamp,
|
||||||
this.author,
|
this.author,
|
||||||
)
|
)
|
||||||
|
@ -7,7 +7,7 @@ import app.dapk.st.matrix.sync.RoomEvent
|
|||||||
fun aMatrixRoomMessageEvent(
|
fun aMatrixRoomMessageEvent(
|
||||||
eventId: EventId = anEventId(),
|
eventId: EventId = anEventId(),
|
||||||
utcTimestamp: Long = 0L,
|
utcTimestamp: Long = 0L,
|
||||||
content: String = "message-content",
|
content: RichText = RichText.of("message-content"),
|
||||||
author: RoomMember = aRoomMember(),
|
author: RoomMember = aRoomMember(),
|
||||||
meta: MessageMeta = MessageMeta.FromServer,
|
meta: MessageMeta = MessageMeta.FromServer,
|
||||||
edited: Boolean = false,
|
edited: Boolean = false,
|
||||||
|
@ -5,8 +5,10 @@ package test
|
|||||||
import TestMessage
|
import TestMessage
|
||||||
import TestUser
|
import TestUser
|
||||||
import app.dapk.st.core.extensions.ifNull
|
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.RoomId
|
||||||
import app.dapk.st.matrix.common.RoomMember
|
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.crypto.MatrixMediaDecrypter
|
||||||
import app.dapk.st.matrix.message.MessageService
|
import app.dapk.st.matrix.message.MessageService
|
||||||
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) {
|
suspend fun TestMatrix.expectTextMessage(roomId: RoomId, message: TestMessage) {
|
||||||
println("expecting ${message.content}")
|
println("expecting ${message.content}")
|
||||||
this.client.syncService().room(roomId)
|
this.client.syncService().room(roomId)
|
||||||
.map { it.events.filterIsInstance<RoomEvent.Message>().map { TestMessage(it.content, it.author) }.firstOrNull() }
|
.map { it.events.filterIsInstance<RoomEvent.Message>().map { TestMessage(it.content.asString(), it.author) }.firstOrNull() }
|
||||||
.assert(message)
|
.assert(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,7 +172,7 @@ class MatrixTestScope(private val testScope: TestScope) {
|
|||||||
println("sending $content")
|
println("sending $content")
|
||||||
this.client.messageService().scheduleMessage(
|
this.client.messageService().scheduleMessage(
|
||||||
MessageService.Message.TextMessage(
|
MessageService.Message.TextMessage(
|
||||||
content = MessageService.Message.Content.TextContent(body = content),
|
content = MessageService.Message.Content.TextContent(body = RichText.of(content)),
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
sendEncrypted = isEncrypted,
|
sendEncrypted = isEncrypted,
|
||||||
localId = "local.${UUID.randomUUID()}",
|
localId = "local.${UUID.randomUUID()}",
|
||||||
|
@ -7,7 +7,7 @@ const config = {
|
|||||||
rcBranchesFrom: "main",
|
rcBranchesFrom: "main",
|
||||||
rcMergesTo: "release",
|
rcMergesTo: "release",
|
||||||
packageName: "app.dapk.st",
|
packageName: "app.dapk.st",
|
||||||
matrixRoomId: "!jgNenzNPtSpJLjjsxe:matrix.org"
|
matrixRoomId: "!fuHEgUsoPRBQynkdkF:iswell.cool"
|
||||||
}
|
}
|
||||||
|
|
||||||
const rcBranchName = "release-candidate"
|
const rcBranchName = "release-candidate"
|
||||||
@ -175,4 +175,4 @@ const readVersionFile = async (github, branch) => {
|
|||||||
content: JSON.parse(content),
|
content: JSON.parse(content),
|
||||||
sha: result.data.sha,
|
sha: result.data.sha,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -50,7 +50,7 @@ export const release = async (github, version, applicationId, artifacts, config)
|
|||||||
owner: config.owner,
|
owner: config.owner,
|
||||||
repo: config.repo,
|
repo: config.repo,
|
||||||
tag_name: version.name,
|
tag_name: version.name,
|
||||||
prerelease: true,
|
prerelease: false,
|
||||||
generate_release_notes: true,
|
generate_release_notes: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -219,4 +219,4 @@ const sendReleaseMessage = async (release, config) => {
|
|||||||
"msgtype": "m.text"
|
"msgtype": "m.text"
|
||||||
}
|
}
|
||||||
await client.sendEvent(config.matrixRoomId, "m.room.message", content, "")
|
await client.sendEvent(config.matrixRoomId, "m.room.message", content, "")
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"code": 24,
|
"code": 25,
|
||||||
"name": "27/10/2022-V1"
|
"name": "31/10/2022-V1"
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user