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) {
|
||||
applicationScope.launch {
|
||||
featureModules.homeModule.betaVersionUpgradeUseCase.waitUnitReady()
|
||||
|
||||
storeModule.credentialsStore().credentials()?.let {
|
||||
featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
|
||||
}
|
||||
runCatching { storeModule.localEchoStore.preload() }
|
||||
}
|
||||
|
||||
applicationScope.launch {
|
||||
val notificationsUseCase = notificationsModule.notificationsUseCase()
|
||||
notificationsUseCase.listenForNotificationChanges(this)
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.ExifInterface
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.OpenableColumns
|
||||
@ -19,6 +20,7 @@ import app.dapk.st.directory.DirectoryModule
|
||||
import app.dapk.st.domain.StoreModule
|
||||
import app.dapk.st.engine.MatrixEngine
|
||||
import app.dapk.st.firebase.messaging.MessagingModule
|
||||
import app.dapk.st.home.BetaVersionUpgradeUseCase
|
||||
import app.dapk.st.home.HomeModule
|
||||
import app.dapk.st.home.MainActivity
|
||||
import app.dapk.st.imageloader.ImageLoaderModule
|
||||
@ -163,7 +165,16 @@ internal class FeatureModules internal constructor(
|
||||
deviceMeta,
|
||||
)
|
||||
}
|
||||
val homeModule by unsafeLazy { HomeModule(chatEngineModule.engine, storeModule.value, buildMeta) }
|
||||
val homeModule by unsafeLazy {
|
||||
HomeModule(
|
||||
chatEngineModule.engine,
|
||||
storeModule.value,
|
||||
BetaVersionUpgradeUseCase(
|
||||
storeModule.value.applicationStore(),
|
||||
buildMeta,
|
||||
),
|
||||
)
|
||||
}
|
||||
val settingsModule by unsafeLazy {
|
||||
SettingsModule(
|
||||
chatEngineModule.engine,
|
||||
@ -295,9 +306,14 @@ internal class AndroidImageContentReader(private val contentResolver: ContentRes
|
||||
cursor.getLong(columnIndex)
|
||||
} ?: throw IllegalArgumentException("Could not process $uri")
|
||||
|
||||
val shouldSwapSizes = ExifInterface(contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri")).let {
|
||||
val orientation = it.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
|
||||
orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270
|
||||
}
|
||||
|
||||
return ImageContentReader.ImageContent(
|
||||
height = options.outHeight,
|
||||
width = options.outWidth,
|
||||
height = if (shouldSwapSizes) options.outWidth else options.outHeight,
|
||||
width = if (shouldSwapSizes) options.outHeight else options.outWidth,
|
||||
size = fileSize,
|
||||
mimeType = options.outMimeType,
|
||||
fileName = androidUri.lastPathSegment ?: "file",
|
||||
|
@ -125,7 +125,7 @@ sealed class RoomEvent {
|
||||
data class Message(
|
||||
override val eventId: EventId,
|
||||
override val utcTimestamp: Long,
|
||||
val content: String,
|
||||
val content: RichText,
|
||||
override val author: RoomMember,
|
||||
override val meta: MessageMeta,
|
||||
override val edited: Boolean = false,
|
||||
|
@ -23,7 +23,7 @@ fun aRoomOverview(
|
||||
fun anEncryptedRoomMessageEvent(
|
||||
eventId: EventId = anEventId(),
|
||||
utcTimestamp: Long = 0L,
|
||||
content: String = "encrypted-content",
|
||||
content: RichText = RichText.of("encrypted-content"),
|
||||
author: RoomMember = aRoomMember(),
|
||||
meta: MessageMeta = MessageMeta.FromServer,
|
||||
edited: Boolean = false,
|
||||
@ -47,7 +47,7 @@ fun aRoomReplyMessageEvent(
|
||||
fun aRoomMessageEvent(
|
||||
eventId: EventId = anEventId(),
|
||||
utcTimestamp: Long = 0L,
|
||||
content: String = "message-content",
|
||||
content: RichText = RichText.of("message-content"),
|
||||
author: RoomMember = aRoomMember(),
|
||||
meta: MessageMeta = MessageMeta.FromServer,
|
||||
edited: Boolean = false,
|
||||
|
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 sqldelightVer = "1.5.4"
|
||||
def composeVer = "1.2.1"
|
||||
def ktorVer = "2.1.2"
|
||||
def ktorVer = "2.1.3"
|
||||
|
||||
google = new DependenciesContainer()
|
||||
google.with {
|
||||
androidGradlePlugin = "com.android.tools.build:gradle:7.3.0"
|
||||
androidGradlePlugin = "com.android.tools.build:gradle:7.3.1"
|
||||
|
||||
androidxComposeUi = "androidx.compose.ui:ui:${composeVer}"
|
||||
androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}"
|
||||
@ -143,7 +143,7 @@ ext.Dependencies.with {
|
||||
ktorContentNegotiation = "io.ktor:ktor-client-content-negotiation:${ktorVer}"
|
||||
|
||||
coil = "io.coil-kt:coil-compose:2.2.2"
|
||||
accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.25.1"
|
||||
accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.27.0"
|
||||
|
||||
junit = "junit:junit:4.13.2"
|
||||
kluent = "org.amshove.kluent:kluent:1.72"
|
||||
|
@ -7,6 +7,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -15,18 +16,26 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import app.dapk.st.core.RichText
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
|
||||
private val ENCRYPTED_MESSAGE = RichText(listOf(RichText.Part.Normal("Encrypted message")))
|
||||
|
||||
sealed interface BubbleModel {
|
||||
val event: Event
|
||||
|
||||
data class Text(val content: String, override val event: Event) : BubbleModel
|
||||
data class Text(val content: RichText, override val event: Event) : BubbleModel
|
||||
data class Encrypted(override val event: Event) : BubbleModel
|
||||
data class Image(val imageContent: ImageContent, val imageRequest: ImageRequest, override val event: Event) : BubbleModel {
|
||||
data class ImageContent(val width: Int?, val height: Int?, val url: String)
|
||||
@ -38,24 +47,29 @@ sealed interface BubbleModel {
|
||||
|
||||
data class Event(val authorId: String, val authorName: String, val edited: Boolean, val time: String)
|
||||
|
||||
|
||||
data class Action(
|
||||
val onLongClick: (BubbleModel) -> Unit,
|
||||
val onImageClick: (Image) -> Unit,
|
||||
)
|
||||
}
|
||||
|
||||
private fun BubbleModel.Reply.isReplyingToSelf() = this.replyingTo.event.authorId == this.reply.event.authorId
|
||||
|
||||
@Composable
|
||||
fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, onLongClick: (BubbleModel) -> Unit) {
|
||||
val itemisedLongClick = { onLongClick.invoke(model) }
|
||||
fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, actions: BubbleModel.Action) {
|
||||
val itemisedLongClick = { actions.onLongClick.invoke(model) }
|
||||
when (model) {
|
||||
is BubbleModel.Text -> TextBubble(bubble, model, status, itemisedLongClick)
|
||||
is BubbleModel.Encrypted -> EncryptedBubble(bubble, model, status, itemisedLongClick)
|
||||
is BubbleModel.Image -> ImageBubble(bubble, model, status, itemisedLongClick)
|
||||
is BubbleModel.Image -> ImageBubble(bubble, model, status, onItemClick = { actions.onImageClick(model) }, itemisedLongClick)
|
||||
is BubbleModel.Reply -> ReplyBubble(bubble, model, status, itemisedLongClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
||||
Bubble(bubble, onLongClick) {
|
||||
Bubble(bubble, onItemClick = {}, onLongClick) {
|
||||
if (bubble.isNotSelf()) {
|
||||
AuthorName(model.event, bubble)
|
||||
}
|
||||
@ -66,12 +80,12 @@ private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Com
|
||||
|
||||
@Composable
|
||||
private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
||||
TextBubble(bubble, BubbleModel.Text(content = "Encrypted message", model.event), status, onLongClick)
|
||||
TextBubble(bubble, BubbleModel.Text(content = ENCRYPTED_MESSAGE, model.event), status, onLongClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
||||
Bubble(bubble, onLongClick) {
|
||||
private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onItemClick: () -> Unit, onLongClick: () -> Unit) {
|
||||
Bubble(bubble, onItemClick, onLongClick) {
|
||||
if (bubble.isNotSelf()) {
|
||||
AuthorName(model.event, bubble)
|
||||
}
|
||||
@ -88,7 +102,7 @@ private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @C
|
||||
|
||||
@Composable
|
||||
private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
||||
Bubble(bubble, onLongClick) {
|
||||
Bubble(bubble, onItemClick = {}, onLongClick) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@ -111,7 +125,7 @@ private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @C
|
||||
when (val replyingTo = model.replyingTo) {
|
||||
is BubbleModel.Text -> {
|
||||
Text(
|
||||
text = replyingTo.content,
|
||||
text = replyingTo.content.toAnnotatedText(),
|
||||
color = bubble.textColor().copy(alpha = 0.8f),
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
@ -153,7 +167,7 @@ private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @C
|
||||
|
||||
when (val message = model.reply) {
|
||||
is BubbleModel.Text -> TextContent(bubble, message.content)
|
||||
is BubbleModel.Encrypted -> TextContent(bubble, "Encrypted message")
|
||||
is BubbleModel.Encrypted -> TextContent(bubble, ENCRYPTED_MESSAGE)
|
||||
is BubbleModel.Image -> {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Image(
|
||||
@ -195,7 +209,7 @@ private fun Int.scalerFor(max: Float): Float {
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
private fun Bubble(bubble: BubbleMeta, onItemClick: () -> Unit, onLongClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
Box(modifier = Modifier.padding(start = 6.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
@ -203,7 +217,7 @@ private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Compos
|
||||
.clip(bubble.shape)
|
||||
.background(bubble.background)
|
||||
.height(IntrinsicSize.Max)
|
||||
.combinedClickable(onLongClick = onLongClick, onClick = {}),
|
||||
.combinedClickable(onLongClick = onLongClick, onClick = onItemClick),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
@ -233,16 +247,50 @@ private fun Footer(event: BubbleModel.Event, bubble: BubbleMeta, status: @Compos
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextContent(bubble: BubbleMeta, text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
color = bubble.textColor(),
|
||||
fontSize = 15.sp,
|
||||
private fun TextContent(bubble: BubbleMeta, text: RichText) {
|
||||
val annotatedText = text.toAnnotatedText()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
ClickableText(
|
||||
text = annotatedText,
|
||||
style = TextStyle(color = bubble.textColor(), fontSize = 15.sp, textAlign = TextAlign.Start),
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
textAlign = TextAlign.Start,
|
||||
onClick = {
|
||||
annotatedText.getStringAnnotations("url", it, it).firstOrNull()?.let {
|
||||
uriHandler.openUri(it.item)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val hyperLinkStyle = SpanStyle(
|
||||
color = Color(0xff64B5F6),
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
|
||||
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
|
||||
private fun AuthorName(event: BubbleModel.Event, bubble: BubbleMeta) {
|
||||
Text(
|
||||
|
@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
|
||||
@ -16,13 +17,14 @@ fun Toolbar(
|
||||
onNavigate: (() -> Unit)? = null,
|
||||
title: String? = null,
|
||||
offset: (Density.() -> IntOffset)? = null,
|
||||
color: Color = MaterialTheme.colorScheme.background,
|
||||
actions: @Composable RowScope.() -> Unit = {}
|
||||
) {
|
||||
val navigationIcon = foo(onNavigate)
|
||||
TopAppBar(
|
||||
modifier = offset?.let { Modifier.offset(it) } ?: Modifier,
|
||||
colors = TopAppBarDefaults.smallTopAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
containerColor = color,
|
||||
),
|
||||
navigationIcon = navigationIcon,
|
||||
title = title?.let { { Text(it, maxLines = 2) } } ?: {},
|
||||
|
@ -13,7 +13,10 @@ import app.dapk.st.matrix.sync.RoomStore
|
||||
import com.squareup.sqldelight.runtime.coroutines.asFlow
|
||||
import com.squareup.sqldelight.runtime.coroutines.mapToList
|
||||
import com.squareup.sqldelight.runtime.coroutines.mapToOneNotNull
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private val json = Json
|
||||
|
@ -3,24 +3,44 @@ package app.dapk.st.home
|
||||
import app.dapk.st.core.BuildMeta
|
||||
import app.dapk.st.domain.ApplicationPreferences
|
||||
import app.dapk.st.domain.ApplicationVersion
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class BetaVersionUpgradeUseCase(
|
||||
private val applicationPreferences: ApplicationPreferences,
|
||||
private val buildMeta: BuildMeta,
|
||||
) {
|
||||
|
||||
private var _continuation: CancellableContinuation<Unit>? = null
|
||||
|
||||
fun hasVersionChanged(): Boolean {
|
||||
return runBlocking {
|
||||
return runBlocking { hasChangedVersion() }
|
||||
}
|
||||
|
||||
private suspend fun hasChangedVersion(): Boolean {
|
||||
val previousVersion = applicationPreferences.readVersion()?.value
|
||||
val currentVersion = buildMeta.versionCode
|
||||
when (previousVersion) {
|
||||
return when (previousVersion) {
|
||||
null -> false
|
||||
else -> currentVersion > previousVersion
|
||||
}.also {
|
||||
applicationPreferences.setVersion(ApplicationVersion(currentVersion))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun waitUnitReady() {
|
||||
if (hasChangedVersion()) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
_continuation = continuation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyUpgraded() {
|
||||
_continuation?.resume(Unit)
|
||||
_continuation = null
|
||||
}
|
||||
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
package app.dapk.st.home
|
||||
|
||||
import app.dapk.st.core.BuildMeta
|
||||
import app.dapk.st.core.ProvidableModule
|
||||
import app.dapk.st.directory.DirectoryViewModel
|
||||
import app.dapk.st.domain.StoreModule
|
||||
@ -11,7 +10,7 @@ import app.dapk.st.profile.ProfileViewModel
|
||||
class HomeModule(
|
||||
private val chatEngine: ChatEngine,
|
||||
private val storeModule: StoreModule,
|
||||
private val buildMeta: BuildMeta,
|
||||
val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
|
||||
) : ProvidableModule {
|
||||
|
||||
fun homeViewModel(directory: DirectoryViewModel, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel {
|
||||
@ -22,10 +21,7 @@ class HomeModule(
|
||||
login,
|
||||
profileViewModel,
|
||||
storeModule.cacheCleaner(),
|
||||
BetaVersionUpgradeUseCase(
|
||||
storeModule.applicationStore(),
|
||||
buildMeta,
|
||||
),
|
||||
betaVersionUpgradeUseCase,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,6 @@ import app.dapk.st.profile.ProfileViewModel
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@ -87,6 +86,7 @@ class HomeViewModel(
|
||||
fun clearCache() {
|
||||
viewModelScope.launch {
|
||||
cacheCleaner.cleanCache(removeCredentials = false)
|
||||
betaVersionUpgradeUseCase.notifyUpgraded()
|
||||
_events.emit(HomeEvent.Relaunch)
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package app.dapk.st.messenger
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.tween
|
||||
@ -8,6 +9,7 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@ -24,7 +26,11 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@ -42,6 +48,7 @@ import app.dapk.st.engine.MessageMeta
|
||||
import app.dapk.st.engine.MessengerState
|
||||
import app.dapk.st.engine.RoomEvent
|
||||
import app.dapk.st.engine.RoomState
|
||||
import app.dapk.st.matrix.common.RichText
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.UserId
|
||||
import app.dapk.st.messenger.gallery.ImageGalleryActivityPayload
|
||||
@ -50,6 +57,8 @@ import app.dapk.st.navigator.Navigator
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
internal fun MessengerScreen(
|
||||
@ -75,7 +84,8 @@ internal fun MessengerScreen(
|
||||
val messageActions = MessageActions(
|
||||
onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) },
|
||||
onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) },
|
||||
onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) }
|
||||
onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) },
|
||||
onImageClick = { viewModel.selectImage(it) }
|
||||
)
|
||||
|
||||
Column {
|
||||
@ -84,6 +94,7 @@ internal fun MessengerScreen(
|
||||
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
|
||||
// }
|
||||
})
|
||||
|
||||
when (state.composerState) {
|
||||
is ComposerState.Text -> {
|
||||
Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) })
|
||||
@ -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
|
||||
@ -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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -200,7 +296,7 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio
|
||||
onReply = { messageActions.onReply(item) },
|
||||
) {
|
||||
val status = @Composable { SendStatus(item) }
|
||||
MessageBubble(this, item.toModel(), status, onLongClick = messageActions.onLongClick)
|
||||
MessageBubble(this, item.toModel(), status, bubbleActions)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -210,7 +306,7 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio
|
||||
private fun RoomEvent.toModel(): BubbleModel {
|
||||
val event = BubbleModel.Event(this.author.id.value, this.author.displayName ?: this.author.id.value, this.edited, this.time)
|
||||
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.Image -> {
|
||||
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
|
||||
private fun SendStatus(message: RoomEvent) {
|
||||
when (val meta = message.meta) {
|
||||
@ -269,7 +378,13 @@ private fun SendStatus(message: RoomEvent) {
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit, messageActions: MessageActions) {
|
||||
private fun TextComposer(
|
||||
state: ComposerState.Text,
|
||||
onTextChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onAttach: () -> Unit,
|
||||
messageActions: MessageActions
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@ -320,7 +435,7 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un
|
||||
)
|
||||
|
||||
Text(
|
||||
text = it.content,
|
||||
text = it.content.toApp().toAnnotatedText(),
|
||||
color = SmallTalkTheme.extendedColors.onOthersBubble,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 2,
|
||||
@ -352,6 +467,7 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un
|
||||
modifier = Modifier.clickable { onAttach() }.wrapContentWidth().align(Alignment.Bottom),
|
||||
imageVector = Icons.Filled.Image,
|
||||
contentDescription = "",
|
||||
tint = SmallTalkTheme.extendedColors.onOthersBubble.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -433,4 +549,5 @@ class MessageActions(
|
||||
val onReply: (RoomEvent) -> Unit,
|
||||
val onDismiss: () -> Unit,
|
||||
val onLongClick: (BubbleModel) -> Unit,
|
||||
val onImageClick: (BubbleModel.Image) -> Unit,
|
||||
)
|
||||
|
@ -1,6 +1,7 @@
|
||||
package app.dapk.st.messenger
|
||||
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.design.components.BubbleModel
|
||||
import app.dapk.st.engine.MessengerState
|
||||
import app.dapk.st.engine.RoomEvent
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
@ -10,6 +11,11 @@ data class MessengerScreenState(
|
||||
val roomId: RoomId?,
|
||||
val roomState: Lce<MessengerState>,
|
||||
val composerState: ComposerState,
|
||||
val viewerState: ViewerState?
|
||||
)
|
||||
|
||||
data class ViewerState(
|
||||
val event: BubbleModel.Image,
|
||||
)
|
||||
|
||||
sealed interface MessengerEvent {
|
||||
|
@ -4,6 +4,7 @@ import android.os.Build
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.DeviceMeta
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.asString
|
||||
import app.dapk.st.core.extensions.takeIfContent
|
||||
import app.dapk.st.design.components.BubbleModel
|
||||
import app.dapk.st.domain.application.message.MessageOptionsStore
|
||||
@ -11,6 +12,7 @@ import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.engine.RoomEvent
|
||||
import app.dapk.st.engine.SendMessage
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.asString
|
||||
import app.dapk.st.navigator.MessageAttachment
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import app.dapk.st.viewmodel.MutableStateFactory
|
||||
@ -30,7 +32,8 @@ internal class MessengerViewModel(
|
||||
initialState = MessengerScreenState(
|
||||
roomId = null,
|
||||
roomState = Lce.Loading(),
|
||||
composerState = ComposerState.Text(value = "", reply = null)
|
||||
composerState = ComposerState.Text(value = "", reply = null),
|
||||
viewerState = null,
|
||||
),
|
||||
factory = factory,
|
||||
) {
|
||||
@ -116,7 +119,7 @@ internal class MessengerViewModel(
|
||||
originalMessage = when (it) {
|
||||
is RoomEvent.Image -> TODO()
|
||||
is RoomEvent.Reply -> TODO()
|
||||
is RoomEvent.Message -> it.content
|
||||
is RoomEvent.Message -> it.content.asString()
|
||||
is RoomEvent.Encrypted -> error("Should never happen")
|
||||
},
|
||||
eventId = it.eventId,
|
||||
@ -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) {
|
||||
is BubbleModel.Encrypted -> CopyableResult.NothingToCopy
|
||||
is BubbleModel.Image -> CopyableResult.NothingToCopy
|
||||
is BubbleModel.Reply -> this.reply.findCopyableContent()
|
||||
is BubbleModel.Text -> CopyableResult.Content(CopyToClipboard.Copyable.Text(this.content))
|
||||
is BubbleModel.Text -> CopyableResult.Content(CopyToClipboard.Copyable.Text(this.content.asString()))
|
||||
}
|
||||
|
||||
private sealed interface CopyableResult {
|
||||
|
@ -48,7 +48,8 @@ class MessengerViewModelTest {
|
||||
MessengerScreenState(
|
||||
roomId = null,
|
||||
roomState = Lce.Loading(),
|
||||
composerState = ComposerState.Text(value = "", reply = null)
|
||||
composerState = ComposerState.Text(value = "", reply = null),
|
||||
viewerState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -114,7 +115,8 @@ class MessengerViewModelTest {
|
||||
fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState(
|
||||
roomId = roomId,
|
||||
roomState = Lce.Content(roomState),
|
||||
composerState = ComposerState.Text(value = messageContent ?: "", reply = null)
|
||||
composerState = ComposerState.Text(value = messageContent ?: "", reply = null),
|
||||
viewerState = null,
|
||||
)
|
||||
|
||||
class FakeCopyToClipboard {
|
||||
|
@ -2,6 +2,7 @@ package app.dapk.st.notifications
|
||||
|
||||
import app.dapk.st.engine.RoomEvent
|
||||
import app.dapk.st.matrix.common.RoomMember
|
||||
import app.dapk.st.matrix.common.asString
|
||||
|
||||
class RoomEventsToNotifiableMapper {
|
||||
|
||||
@ -11,7 +12,7 @@ class RoomEventsToNotifiableMapper {
|
||||
|
||||
private fun RoomEvent.toNotifiableContent(): String = when (this) {
|
||||
is RoomEvent.Image -> "\uD83D\uDCF7"
|
||||
is RoomEvent.Message -> this.content
|
||||
is RoomEvent.Message -> this.content.asString()
|
||||
is RoomEvent.Reply -> this.message.toNotifiableContent()
|
||||
is RoomEvent.Encrypted -> "Encrypted message"
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
package app.dapk.st.notifications
|
||||
|
||||
import app.dapk.st.matrix.common.RichText
|
||||
import app.dapk.st.matrix.common.asString
|
||||
import fixture.aRoomImageMessageEvent
|
||||
import fixture.aRoomMessageEvent
|
||||
import fixture.aRoomReplyMessageEvent
|
||||
@ -18,7 +20,7 @@ class RoomEventsToNotifiableMapperTest {
|
||||
|
||||
result shouldBeEqualTo listOf(
|
||||
Notifiable(
|
||||
content = event.content,
|
||||
content = event.content.asString(),
|
||||
utcTimestamp = event.utcTimestamp,
|
||||
author = event.author
|
||||
)
|
||||
@ -42,14 +44,14 @@ class RoomEventsToNotifiableMapperTest {
|
||||
|
||||
@Test
|
||||
fun `given reply event with message, when mapping, then uses message for content`() {
|
||||
val reply = aRoomMessageEvent(utcTimestamp = -1, content = "hello")
|
||||
val reply = aRoomMessageEvent(utcTimestamp = -1, content = RichText.of("hello"))
|
||||
val event = aRoomReplyMessageEvent(reply, replyingTo = aRoomImageMessageEvent(utcTimestamp = -1))
|
||||
|
||||
val result = mapper.map(listOf(event))
|
||||
|
||||
result shouldBeEqualTo listOf(
|
||||
Notifiable(
|
||||
content = reply.content,
|
||||
content = reply.content.asString(),
|
||||
utcTimestamp = event.utcTimestamp,
|
||||
author = event.author
|
||||
)
|
||||
|
@ -1,9 +1,6 @@
|
||||
package app.dapk.st.engine
|
||||
|
||||
import app.dapk.st.matrix.common.CredentialsStore
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.RoomMember
|
||||
import app.dapk.st.matrix.common.UserId
|
||||
import app.dapk.st.matrix.common.*
|
||||
import app.dapk.st.matrix.message.MessageService
|
||||
import app.dapk.st.matrix.room.RoomService
|
||||
import app.dapk.st.matrix.sync.RoomStore
|
||||
@ -68,7 +65,7 @@ internal class DirectoryUseCase(
|
||||
this.copy(
|
||||
lastMessage = RoomOverview.LastMessage(
|
||||
content = when (val message = latestEcho.message) {
|
||||
is MessageService.Message.TextMessage -> message.content.body
|
||||
is MessageService.Message.TextMessage -> message.content.body.asString()
|
||||
is MessageService.Message.ImageMessage -> "\uD83D\uDCF7"
|
||||
},
|
||||
utcTimestamp = latestEcho.timestampUtc,
|
||||
|
@ -1,5 +1,6 @@
|
||||
package app.dapk.st.engine
|
||||
|
||||
import app.dapk.st.matrix.common.RichText
|
||||
import app.dapk.st.matrix.message.MessageService
|
||||
import app.dapk.st.matrix.message.internal.ImageContentReader
|
||||
import java.time.Clock
|
||||
@ -41,7 +42,7 @@ internal class SendMessageUseCase(
|
||||
}
|
||||
|
||||
private fun createTextMessage(message: SendMessage.TextMessage, room: RoomOverview) = MessageService.Message.TextMessage(
|
||||
content = MessageService.Message.Content.TextContent(message.content),
|
||||
content = MessageService.Message.Content.TextContent(RichText.of(message.content)),
|
||||
roomId = room.roomId,
|
||||
sendEncrypted = room.isEncrypted,
|
||||
localId = localIdFactory.create(),
|
||||
@ -49,7 +50,7 @@ internal class SendMessageUseCase(
|
||||
reply = message.reply?.let {
|
||||
MessageService.Message.TextMessage.Reply(
|
||||
author = it.author,
|
||||
originalMessage = it.originalMessage,
|
||||
originalMessage = RichText.of(it.originalMessage),
|
||||
replyContent = message.content,
|
||||
eventId = it.eventId,
|
||||
timestampUtc = it.timestampUtc,
|
||||
|
@ -1,6 +1,7 @@
|
||||
package app.dapk.st.engine
|
||||
|
||||
import app.dapk.st.matrix.common.EventId
|
||||
import app.dapk.st.matrix.common.RichText
|
||||
import app.dapk.st.matrix.message.MessageService
|
||||
import fixture.*
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
@ -54,7 +55,7 @@ class MergeWithLocalEchosUseCaseTest {
|
||||
|
||||
private fun createLocalEcho(eventId: EventId, body: String, state: MessageService.LocalEcho.State) = aLocalEcho(
|
||||
eventId,
|
||||
aTextMessage(aTextContent(body)),
|
||||
aTextMessage(aTextContent(RichText.of(body))),
|
||||
state,
|
||||
)
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
package app.dapk.st.engine
|
||||
|
||||
import app.dapk.st.matrix.common.RichText
|
||||
import fake.FakeRoomStore
|
||||
import fixture.NotificationDiffFixtures.aNotificationDiff
|
||||
import fixture.aMatrixRoomMessageEvent
|
||||
@ -15,8 +16,8 @@ import app.dapk.st.matrix.sync.RoomEvent as MatrixRoomEvent
|
||||
import app.dapk.st.matrix.sync.RoomOverview as MatrixRoomOverview
|
||||
|
||||
private val NO_UNREADS = emptyMap<MatrixRoomOverview, List<MatrixRoomEvent>>()
|
||||
private val A_MESSAGE = aMatrixRoomMessageEvent(eventId = anEventId("1"), content = "hello", utcTimestamp = 1000)
|
||||
private val A_MESSAGE_2 = aMatrixRoomMessageEvent(eventId = anEventId("2"), content = "world", utcTimestamp = 2000)
|
||||
private val A_MESSAGE = aMatrixRoomMessageEvent(eventId = anEventId("1"), content = RichText.of("hello"), utcTimestamp = 1000)
|
||||
private val A_MESSAGE_2 = aMatrixRoomMessageEvent(eventId = anEventId("2"), content = RichText.of("world"), utcTimestamp = 2000)
|
||||
private val A_ROOM_OVERVIEW = aMatrixRoomOverview(roomId = aRoomId("1"))
|
||||
private val A_ROOM_OVERVIEW_2 = aMatrixRoomOverview(roomId = aRoomId("2"))
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
package app.dapk.st.engine
|
||||
|
||||
import app.dapk.st.matrix.common.RichText
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.RoomMember
|
||||
import app.dapk.st.matrix.common.UserId
|
||||
@ -24,7 +25,7 @@ import test.delegateReturn
|
||||
private val A_ROOM_ID = aRoomId()
|
||||
private val AN_USER_ID = aUserId()
|
||||
private val A_ROOM_STATE = aMatrixRoomState()
|
||||
private val A_MERGED_ROOM_STATE = A_ROOM_STATE.copy(events = listOf(aMatrixRoomMessageEvent(content = "a merged event")))
|
||||
private val A_MERGED_ROOM_STATE = A_ROOM_STATE.copy(events = listOf(aMatrixRoomMessageEvent(content = RichText.of("a merged event"))))
|
||||
private val A_LOCAL_ECHOS_LIST = listOf(aLocalEcho())
|
||||
private val A_ROOM_MEMBER = aRoomMember()
|
||||
|
||||
|
@ -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
|
||||
data class Reply(
|
||||
val author: RoomMember,
|
||||
val originalMessage: String,
|
||||
val originalMessage: RichText,
|
||||
val replyContent: String,
|
||||
val eventId: EventId,
|
||||
val timestampUtc: Long,
|
||||
@ -65,7 +65,7 @@ interface MessageService : MatrixService {
|
||||
sealed class Content {
|
||||
@Serializable
|
||||
data class TextContent(
|
||||
@SerialName("body") val body: String,
|
||||
@SerialName("body") val body: RichText,
|
||||
@SerialName("msgtype") val type: String = MessageType.TEXT.value,
|
||||
) : Content()
|
||||
|
||||
|
@ -60,7 +60,6 @@ internal class SendMessageUseCase(
|
||||
|
||||
private suspend fun imageMessageRequest(message: Message.ImageMessage): HttpRequest<ApiSendResponse> {
|
||||
val imageMeta = message.content.meta
|
||||
|
||||
return when (message.sendEncrypted) {
|
||||
true -> {
|
||||
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) {
|
||||
null -> ApiMessage.TextMessage.TextContent(
|
||||
body = this.content.body,
|
||||
body = this.content.body.asString(),
|
||||
)
|
||||
|
||||
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)),
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package fixture
|
||||
|
||||
import app.dapk.st.matrix.common.MessageType
|
||||
import app.dapk.st.matrix.common.RichText
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.message.MessageService
|
||||
|
||||
@ -13,6 +14,6 @@ fun aTextMessage(
|
||||
) = MessageService.Message.TextMessage(content, sendEncrypted, roomId, localId, timestampUtc)
|
||||
|
||||
fun aTextContent(
|
||||
body: String = "text content body",
|
||||
body: RichText = RichText.of("text content body"),
|
||||
type: String = MessageType.TEXT.value,
|
||||
) = MessageService.Message.Content.TextContent(body, type)
|
||||
|
@ -45,7 +45,7 @@ sealed class RoomEvent {
|
||||
data class Message(
|
||||
@SerialName("event_id") override val eventId: EventId,
|
||||
@SerialName("timestamp") override val utcTimestamp: Long,
|
||||
@SerialName("content") val content: String,
|
||||
@SerialName("content") val content: RichText,
|
||||
@SerialName("author") override val author: RoomMember,
|
||||
@SerialName("meta") override val meta: MessageMeta,
|
||||
@SerialName("edited") val edited: Boolean = false,
|
||||
|
@ -8,6 +8,7 @@ import app.dapk.st.matrix.sync.internal.DefaultSyncService
|
||||
import app.dapk.st.matrix.sync.internal.request.*
|
||||
import app.dapk.st.matrix.sync.internal.room.MessageDecrypter
|
||||
import app.dapk.st.matrix.sync.internal.room.MissingMessageDecrypter
|
||||
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@ -53,6 +54,7 @@ fun MatrixServiceInstaller.installSyncService(
|
||||
roomMembersService: ServiceDepFactory<RoomMembersService>,
|
||||
errorTracker: ErrorTracker,
|
||||
coroutineDispatchers: CoroutineDispatchers,
|
||||
|
||||
syncConfig: SyncConfig = SyncConfig(),
|
||||
): InstallExtender<SyncService> {
|
||||
this.serializers {
|
||||
@ -96,6 +98,7 @@ fun MatrixServiceInstaller.installSyncService(
|
||||
errorTracker = errorTracker,
|
||||
coroutineDispatchers = coroutineDispatchers,
|
||||
syncConfig = syncConfig,
|
||||
richMessageParser = RichMessageParser()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter
|
||||
import app.dapk.st.matrix.sync.internal.room.SyncEventDecrypter
|
||||
import app.dapk.st.matrix.sync.internal.room.SyncSideEffects
|
||||
import app.dapk.st.matrix.sync.internal.sync.*
|
||||
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
@ -41,13 +42,14 @@ internal class DefaultSyncService(
|
||||
errorTracker: ErrorTracker,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
syncConfig: SyncConfig,
|
||||
richMessageParser: RichMessageParser,
|
||||
) : SyncService {
|
||||
|
||||
private val syncEventsFlow = MutableStateFlow<List<SyncService.SyncEvent>>(emptyList())
|
||||
|
||||
private val roomDataSource by lazy { RoomDataSource(roomStore, logger) }
|
||||
private val eventDecrypter by lazy { SyncEventDecrypter(messageDecrypter, json, logger) }
|
||||
private val roomEventsDecrypter by lazy { RoomEventsDecrypter(messageDecrypter, json, logger) }
|
||||
private val roomEventsDecrypter by lazy { RoomEventsDecrypter(messageDecrypter, richMessageParser, json, logger) }
|
||||
private val roomRefresher by lazy { RoomRefresher(roomDataSource, roomEventsDecrypter, logger) }
|
||||
|
||||
private val sync2 by lazy {
|
||||
@ -57,7 +59,7 @@ internal class DefaultSyncService(
|
||||
roomMembersService,
|
||||
roomDataSource,
|
||||
TimelineEventsProcessor(
|
||||
RoomEventCreator(roomMembersService, errorTracker, RoomEventFactory(roomMembersService)),
|
||||
RoomEventCreator(roomMembersService, errorTracker, RoomEventFactory(roomMembersService, richMessageParser), richMessageParser),
|
||||
roomEventsDecrypter,
|
||||
eventDecrypter,
|
||||
EventLookupUseCase(roomStore)
|
||||
|
@ -6,10 +6,12 @@ import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
|
||||
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent.TimelineMessage.Content.Image
|
||||
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent.TimelineMessage.Content.Text
|
||||
import app.dapk.st.matrix.sync.internal.request.DecryptedContent
|
||||
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
internal class RoomEventsDecrypter(
|
||||
private val messageDecrypter: MessageDecrypter,
|
||||
private val richMessageParser: RichMessageParser,
|
||||
private val json: Json,
|
||||
private val logger: MatrixLogger,
|
||||
) {
|
||||
@ -50,7 +52,7 @@ internal class RoomEventsDecrypter(
|
||||
meta = this.meta,
|
||||
edited = this.edited,
|
||||
redacted = this.redacted,
|
||||
content = content.body ?: ""
|
||||
content = richMessageParser.parse(content.body ?: "")
|
||||
)
|
||||
|
||||
private fun RoomEvent.Encrypted.createImageEvent(content: Image, userCredentials: UserCredentials) = RoomEvent.Image(
|
||||
|
@ -51,7 +51,7 @@ class RoomDataSource(
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoomEvent.redact() = RoomEvent.Message(this.eventId, this.utcTimestamp, "Redacted", this.author, this.meta, redacted = true)
|
||||
private fun RoomEvent.redact() = RoomEvent.Message(this.eventId, this.utcTimestamp, RichText.of("Redacted"), this.author, this.meta, redacted = true)
|
||||
|
||||
private fun RoomState.replaceEvent(old: RoomEvent, new: RoomEvent): RoomState {
|
||||
val updatedEvents = this.events.toMutableList().apply {
|
||||
|
@ -12,6 +12,7 @@ import app.dapk.st.matrix.sync.RoomMembersService
|
||||
import app.dapk.st.matrix.sync.find
|
||||
import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent
|
||||
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
|
||||
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
|
||||
|
||||
private typealias Lookup = suspend (EventId) -> LookupResult
|
||||
|
||||
@ -19,6 +20,7 @@ internal class RoomEventCreator(
|
||||
private val roomMembersService: RoomMembersService,
|
||||
private val errorTracker: ErrorTracker,
|
||||
private val roomEventFactory: RoomEventFactory,
|
||||
private val richMessageParser: RichMessageParser,
|
||||
) {
|
||||
|
||||
suspend fun ApiTimelineEvent.Encrypted.toRoomEvent(roomId: RoomId): RoomEvent? {
|
||||
@ -44,7 +46,7 @@ internal class RoomEventCreator(
|
||||
}
|
||||
|
||||
suspend fun ApiTimelineEvent.TimelineMessage.toRoomEvent(userCredentials: UserCredentials, roomId: RoomId, lookup: Lookup): RoomEvent? {
|
||||
return TimelineEventMapper(userCredentials, roomId, roomEventFactory).mapToRoomEvent(this, lookup)
|
||||
return TimelineEventMapper(userCredentials, roomId, roomEventFactory, richMessageParser).mapToRoomEvent(this, lookup)
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,6 +54,7 @@ internal class TimelineEventMapper(
|
||||
private val userCredentials: UserCredentials,
|
||||
private val roomId: RoomId,
|
||||
private val roomEventFactory: RoomEventFactory,
|
||||
private val richMessageParser: RichMessageParser,
|
||||
) {
|
||||
|
||||
suspend fun mapToRoomEvent(event: ApiTimelineEvent.TimelineMessage, lookup: Lookup): RoomEvent? {
|
||||
@ -138,7 +141,7 @@ internal class TimelineEventMapper(
|
||||
|
||||
is ApiTimelineEvent.TimelineMessage.Content.Text -> original.toTextMessage(
|
||||
utcTimestamp = incomingEdit.utcTimestamp,
|
||||
content = incomingEdit.asTextContent().body?.removePrefix(" * ")?.trim() ?: "redacted",
|
||||
content = incomingEdit.asTextContent().let { it.formattedBody ?: it.body }?.removePrefix(" * ") ?: "redacted",
|
||||
edited = true,
|
||||
)
|
||||
|
||||
@ -148,7 +151,7 @@ internal class TimelineEventMapper(
|
||||
}
|
||||
|
||||
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,
|
||||
edited = true,
|
||||
)
|
||||
@ -156,13 +159,17 @@ internal class TimelineEventMapper(
|
||||
private suspend fun RoomEventFactory.mapToRoomEvent(source: ApiTimelineEvent.TimelineMessage): RoomEvent {
|
||||
return when (source.content) {
|
||||
is ApiTimelineEvent.TimelineMessage.Content.Image -> source.toImageMessage(userCredentials, roomId)
|
||||
is ApiTimelineEvent.TimelineMessage.Content.Text -> source.toTextMessage(roomId)
|
||||
is ApiTimelineEvent.TimelineMessage.Content.Text -> source.toTextMessage(
|
||||
roomId,
|
||||
content = source.asTextContent().formattedBody ?: source.content.body ?: "redacted"
|
||||
)
|
||||
|
||||
ApiTimelineEvent.TimelineMessage.Content.Ignored -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage(
|
||||
content: String = this.asTextContent().formattedBody?.stripTags() ?: this.asTextContent().body ?: "redacted",
|
||||
content: String = this.asTextContent().formattedBody ?: this.asTextContent().body ?: "redacted",
|
||||
edited: Boolean = false,
|
||||
utcTimestamp: Long = this.utcTimestamp,
|
||||
) = with(roomEventFactory) { toTextMessage(roomId, content, edited, utcTimestamp) }
|
||||
|
@ -6,21 +6,23 @@ import app.dapk.st.matrix.sync.RoomEvent
|
||||
import app.dapk.st.matrix.sync.RoomMembersService
|
||||
import app.dapk.st.matrix.sync.find
|
||||
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
|
||||
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
|
||||
|
||||
private val UNKNOWN_AUTHOR = RoomMember(id = UserId("unknown"), displayName = null, avatarUrl = null)
|
||||
|
||||
internal class RoomEventFactory(
|
||||
private val roomMembersService: RoomMembersService
|
||||
private val roomMembersService: RoomMembersService,
|
||||
private val richMessageParser: RichMessageParser,
|
||||
) {
|
||||
|
||||
suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage(
|
||||
roomId: RoomId,
|
||||
content: String = this.asTextContent().formattedBody?.stripTags() ?: this.asTextContent().body ?: "redacted",
|
||||
content: String,
|
||||
edited: Boolean = false,
|
||||
utcTimestamp: Long = this.utcTimestamp,
|
||||
) = RoomEvent.Message(
|
||||
eventId = this.id,
|
||||
content = content,
|
||||
content = richMessageParser.parse(content),
|
||||
author = roomMembersService.find(roomId, this.senderId) ?: UNKNOWN_AUTHOR,
|
||||
utcTimestamp = utcTimestamp,
|
||||
meta = MessageMeta.FromServer,
|
||||
@ -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
|
||||
|
||||
import app.dapk.st.matrix.common.AvatarUrl
|
||||
import app.dapk.st.matrix.common.RoomMember
|
||||
import app.dapk.st.matrix.common.UserCredentials
|
||||
import app.dapk.st.matrix.common.convertMxUrToUrl
|
||||
import app.dapk.st.matrix.common.*
|
||||
import app.dapk.st.matrix.sync.*
|
||||
import app.dapk.st.matrix.sync.internal.request.ApiSyncRoom
|
||||
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
|
||||
@ -79,7 +76,7 @@ internal fun List<RoomEvent>.findLastMessage(): LastMessage? {
|
||||
|
||||
private fun RoomEvent.toTextContent(): String = when (this) {
|
||||
is RoomEvent.Image -> "\uD83D\uDCF7"
|
||||
is RoomEvent.Message -> this.content
|
||||
is RoomEvent.Message -> this.content.asString()
|
||||
is RoomEvent.Reply -> this.message.toTextContent()
|
||||
is RoomEvent.Encrypted -> "Encrypted message"
|
||||
}
|
@ -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.JsonString
|
||||
import app.dapk.st.matrix.common.RichText
|
||||
import app.dapk.st.matrix.sync.RoomEvent
|
||||
import app.dapk.st.matrix.sync.internal.request.DecryptedContent
|
||||
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
|
||||
import fake.FakeMatrixLogger
|
||||
import fake.FakeMessageDecrypter
|
||||
import fixture.*
|
||||
@ -31,6 +33,7 @@ class RoomEventsDecrypterTest {
|
||||
|
||||
private val roomEventsDecrypter = RoomEventsDecrypter(
|
||||
fakeMessageDecrypter,
|
||||
RichMessageParser(),
|
||||
Json,
|
||||
FakeMatrixLogger(),
|
||||
)
|
||||
@ -88,7 +91,7 @@ private fun RoomEvent.Encrypted.MegOlmV1.toModel() = EncryptedMessageContent.Meg
|
||||
private fun RoomEvent.Encrypted.toText(text: String) = RoomEvent.Message(
|
||||
this.eventId,
|
||||
this.utcTimestamp,
|
||||
content = text,
|
||||
content = RichText.of(text),
|
||||
this.author,
|
||||
this.meta,
|
||||
this.edited,
|
||||
|
@ -1,5 +1,6 @@
|
||||
package app.dapk.st.matrix.sync.internal.sync
|
||||
|
||||
import app.dapk.st.matrix.common.RichText
|
||||
import fake.FakeRoomStore
|
||||
import fixture.aMatrixRoomMessageEvent
|
||||
import fixture.anEventId
|
||||
@ -11,8 +12,8 @@ import org.junit.Test
|
||||
|
||||
private val AN_EVENT_ID = anEventId()
|
||||
private val A_TIMELINE_EVENT = anApiTimelineTextEvent(AN_EVENT_ID, content = aTimelineTextEventContent(body = "timeline event"))
|
||||
private val A_ROOM_EVENT = aMatrixRoomMessageEvent(AN_EVENT_ID, content = "previous room event")
|
||||
private val A_PERSISTED_EVENT = aMatrixRoomMessageEvent(AN_EVENT_ID, content = "persisted event")
|
||||
private val A_ROOM_EVENT = aMatrixRoomMessageEvent(AN_EVENT_ID, content = RichText.of("previous room event"))
|
||||
private val A_PERSISTED_EVENT = aMatrixRoomMessageEvent(AN_EVENT_ID, content = RichText.of("persisted event"))
|
||||
|
||||
class EventLookupUseCaseTest {
|
||||
|
||||
|
@ -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
|
||||
|
||||
import app.dapk.st.matrix.common.EventId
|
||||
import app.dapk.st.matrix.common.RichText
|
||||
import app.dapk.st.matrix.common.asString
|
||||
import app.dapk.st.matrix.sync.RoomEvent
|
||||
import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent
|
||||
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
|
||||
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
|
||||
import fake.FakeErrorTracker
|
||||
import fake.FakeRoomMembersService
|
||||
import fixture.*
|
||||
@ -15,11 +18,11 @@ import org.junit.Test
|
||||
private val A_ROOM_ID = aRoomId()
|
||||
private val A_SENDER = aRoomMember()
|
||||
private val EMPTY_LOOKUP = FakeLookup(LookupResult(apiTimelineEvent = null, roomEvent = null))
|
||||
private const val A_TEXT_EVENT_MESSAGE = "a text message"
|
||||
private const val A_REPLY_EVENT_MESSAGE = "a reply to another message"
|
||||
private val A_TEXT_EVENT_MESSAGE = RichText.of("a text message")
|
||||
private val A_REPLY_EVENT_MESSAGE = RichText.of("a reply to another message")
|
||||
private val A_TEXT_EVENT = anApiTimelineTextEvent(
|
||||
senderId = A_SENDER.id,
|
||||
content = aTimelineTextEventContent(body = A_TEXT_EVENT_MESSAGE)
|
||||
content = aTimelineTextEventContent(body = A_TEXT_EVENT_MESSAGE.asString())
|
||||
)
|
||||
private val A_TEXT_EVENT_WITHOUT_CONTENT = anApiTimelineTextEvent(
|
||||
senderId = A_SENDER.id,
|
||||
@ -31,7 +34,11 @@ internal class RoomEventCreatorTest {
|
||||
|
||||
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
|
||||
fun `given Megolm encrypted event then maps to encrypted room message`() = runTest {
|
||||
@ -89,7 +96,7 @@ internal class RoomEventCreatorTest {
|
||||
result shouldBeEqualTo aMatrixRoomMessageEvent(
|
||||
eventId = A_TEXT_EVENT_WITHOUT_CONTENT.id,
|
||||
utcTimestamp = A_TEXT_EVENT_WITHOUT_CONTENT.utcTimestamp,
|
||||
content = "redacted",
|
||||
content = RichText.of("redacted"),
|
||||
author = A_SENDER,
|
||||
)
|
||||
}
|
||||
@ -97,14 +104,14 @@ internal class RoomEventCreatorTest {
|
||||
@Test
|
||||
fun `given edited event with no relation then maps to new room message`() = runTest {
|
||||
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||
val editEvent = anApiTimelineTextEvent().toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE)
|
||||
val editEvent = anApiTimelineTextEvent().toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE.asString())
|
||||
|
||||
val result = with(roomEventCreator) { editEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) }
|
||||
|
||||
result shouldBeEqualTo aMatrixRoomMessageEvent(
|
||||
eventId = editEvent.id,
|
||||
utcTimestamp = editEvent.utcTimestamp,
|
||||
content = editEvent.asTextContent().body!!,
|
||||
content = RichText.of(editEvent.asTextContent().body!!.trimStart()),
|
||||
author = A_SENDER,
|
||||
edited = true
|
||||
)
|
||||
@ -114,7 +121,7 @@ internal class RoomEventCreatorTest {
|
||||
fun `given edited event which relates to a timeline event then updates existing message`() = runTest {
|
||||
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||
val originalMessage = anApiTimelineTextEvent(utcTimestamp = 0)
|
||||
val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE)
|
||||
val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE.asString())
|
||||
val lookup = givenLookup(originalMessage)
|
||||
|
||||
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||
@ -132,7 +139,7 @@ internal class RoomEventCreatorTest {
|
||||
fun `given edited event which relates to a room event then updates existing message`() = runTest {
|
||||
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||
val originalMessage = aMatrixRoomMessageEvent()
|
||||
val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE)
|
||||
val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE.asString())
|
||||
val lookup = givenLookup(originalMessage)
|
||||
|
||||
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||
@ -150,7 +157,7 @@ internal class RoomEventCreatorTest {
|
||||
fun `given edited event which relates to a room reply event then only updates message`() = runTest {
|
||||
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||
val originalMessage = aRoomReplyMessageEvent(message = aMatrixRoomMessageEvent())
|
||||
val editedMessage = (originalMessage.message as RoomEvent.Message).toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE)
|
||||
val editedMessage = (originalMessage.message as RoomEvent.Message).toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE.asString())
|
||||
val lookup = givenLookup(originalMessage)
|
||||
|
||||
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||
@ -170,7 +177,7 @@ internal class RoomEventCreatorTest {
|
||||
@Test
|
||||
fun `given edited event is older than related known timeline event then ignores edit`() = runTest {
|
||||
val originalMessage = anApiTimelineTextEvent(utcTimestamp = 1000)
|
||||
val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE)
|
||||
val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE.asString())
|
||||
val lookup = givenLookup(originalMessage)
|
||||
|
||||
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||
@ -181,7 +188,7 @@ internal class RoomEventCreatorTest {
|
||||
@Test
|
||||
fun `given edited event is older than related room event then ignores edit`() = runTest {
|
||||
val originalMessage = aMatrixRoomMessageEvent(utcTimestamp = 1000)
|
||||
val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE)
|
||||
val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE.asString())
|
||||
val lookup = givenLookup(originalMessage)
|
||||
|
||||
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||
@ -192,7 +199,7 @@ internal class RoomEventCreatorTest {
|
||||
@Test
|
||||
fun `given reply event with no relation then maps to new room message using the full body`() = runTest {
|
||||
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||
val replyEvent = anApiTimelineTextEvent().toReplyEvent(messageContent = A_TEXT_EVENT_MESSAGE)
|
||||
val replyEvent = anApiTimelineTextEvent().toReplyEvent(messageContent = A_TEXT_EVENT_MESSAGE.asString())
|
||||
|
||||
println(replyEvent.content)
|
||||
val result = with(roomEventCreator) { replyEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) }
|
||||
@ -200,7 +207,7 @@ internal class RoomEventCreatorTest {
|
||||
result shouldBeEqualTo aMatrixRoomMessageEvent(
|
||||
eventId = replyEvent.id,
|
||||
utcTimestamp = replyEvent.utcTimestamp,
|
||||
content = replyEvent.asTextContent().body!!,
|
||||
content = RichText.of(replyEvent.asTextContent().body!!),
|
||||
author = A_SENDER,
|
||||
)
|
||||
}
|
||||
@ -209,7 +216,7 @@ internal class RoomEventCreatorTest {
|
||||
fun `given reply event which relates to a timeline event then maps to reply`() = runTest {
|
||||
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||
val originalMessage = anApiTimelineTextEvent(content = aTimelineTextEventContent(body = "message being replied to"))
|
||||
val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE)
|
||||
val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE.asString())
|
||||
val lookup = givenLookup(originalMessage)
|
||||
|
||||
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||
@ -218,7 +225,7 @@ internal class RoomEventCreatorTest {
|
||||
replyingTo = aMatrixRoomMessageEvent(
|
||||
eventId = originalMessage.id,
|
||||
utcTimestamp = originalMessage.utcTimestamp,
|
||||
content = originalMessage.asTextContent().body!!,
|
||||
content = RichText.of(originalMessage.asTextContent().body!!),
|
||||
author = A_SENDER,
|
||||
),
|
||||
message = aMatrixRoomMessageEvent(
|
||||
@ -234,7 +241,7 @@ internal class RoomEventCreatorTest {
|
||||
fun `given reply event which relates to a room event then maps to reply`() = runTest {
|
||||
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||
val originalMessage = aMatrixRoomMessageEvent()
|
||||
val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE)
|
||||
val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE.asString())
|
||||
val lookup = givenLookup(originalMessage)
|
||||
|
||||
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||
@ -254,7 +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 {
|
||||
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
|
||||
val originalMessage = aRoomReplyMessageEvent()
|
||||
val replyMessage = (originalMessage.message as RoomEvent.Message).toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE)
|
||||
val replyMessage = (originalMessage.message as RoomEvent.Message).toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE.asString())
|
||||
val lookup = givenLookup(originalMessage)
|
||||
|
||||
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
|
||||
|
@ -1,6 +1,7 @@
|
||||
package app.dapk.st.matrix.sync.internal.sync
|
||||
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.asString
|
||||
import app.dapk.st.matrix.sync.RoomEvent
|
||||
import app.dapk.st.matrix.sync.RoomState
|
||||
import fake.FakeMatrixLogger
|
||||
@ -60,7 +61,7 @@ internal class RoomRefresherTest {
|
||||
}
|
||||
|
||||
private fun RoomEvent.Message.asLastMessage() = aLastMessage(
|
||||
this.content,
|
||||
this.content.asString(),
|
||||
this.utcTimestamp,
|
||||
this.author,
|
||||
)
|
||||
|
@ -7,7 +7,7 @@ import app.dapk.st.matrix.sync.RoomEvent
|
||||
fun aMatrixRoomMessageEvent(
|
||||
eventId: EventId = anEventId(),
|
||||
utcTimestamp: Long = 0L,
|
||||
content: String = "message-content",
|
||||
content: RichText = RichText.of("message-content"),
|
||||
author: RoomMember = aRoomMember(),
|
||||
meta: MessageMeta = MessageMeta.FromServer,
|
||||
edited: Boolean = false,
|
||||
|
@ -5,8 +5,10 @@ package test
|
||||
import TestMessage
|
||||
import TestUser
|
||||
import app.dapk.st.core.extensions.ifNull
|
||||
import app.dapk.st.matrix.common.RichText
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.RoomMember
|
||||
import app.dapk.st.matrix.common.asString
|
||||
import app.dapk.st.matrix.crypto.MatrixMediaDecrypter
|
||||
import app.dapk.st.matrix.message.MessageService
|
||||
import app.dapk.st.matrix.message.messageService
|
||||
@ -138,7 +140,7 @@ class MatrixTestScope(private val testScope: TestScope) {
|
||||
suspend fun TestMatrix.expectTextMessage(roomId: RoomId, message: TestMessage) {
|
||||
println("expecting ${message.content}")
|
||||
this.client.syncService().room(roomId)
|
||||
.map { it.events.filterIsInstance<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)
|
||||
}
|
||||
|
||||
@ -170,7 +172,7 @@ class MatrixTestScope(private val testScope: TestScope) {
|
||||
println("sending $content")
|
||||
this.client.messageService().scheduleMessage(
|
||||
MessageService.Message.TextMessage(
|
||||
content = MessageService.Message.Content.TextContent(body = content),
|
||||
content = MessageService.Message.Content.TextContent(body = RichText.of(content)),
|
||||
roomId = roomId,
|
||||
sendEncrypted = isEncrypted,
|
||||
localId = "local.${UUID.randomUUID()}",
|
||||
|
@ -7,7 +7,7 @@ const config = {
|
||||
rcBranchesFrom: "main",
|
||||
rcMergesTo: "release",
|
||||
packageName: "app.dapk.st",
|
||||
matrixRoomId: "!jgNenzNPtSpJLjjsxe:matrix.org"
|
||||
matrixRoomId: "!fuHEgUsoPRBQynkdkF:iswell.cool"
|
||||
}
|
||||
|
||||
const rcBranchName = "release-candidate"
|
||||
|
@ -50,7 +50,7 @@ export const release = async (github, version, applicationId, artifacts, config)
|
||||
owner: config.owner,
|
||||
repo: config.repo,
|
||||
tag_name: version.name,
|
||||
prerelease: true,
|
||||
prerelease: false,
|
||||
generate_release_notes: true,
|
||||
})
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
{
|
||||
"code": 24,
|
||||
"name": "27/10/2022-V1"
|
||||
"code": 25,
|
||||
"name": "31/10/2022-V1"
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user