Merge pull request #232 from ouchadam/release-candidate

[Auto] Release Candidate
This commit is contained in:
Adam Brown 2022-10-31 19:07:16 +00:00 committed by GitHub
commit 58730470f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1178 additions and 164 deletions

View File

@ -60,13 +60,12 @@ class SmallTalkApplication : Application(), ModuleProvider {
private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) { private fun onApplicationLaunch(notificationsModule: NotificationsModule, storeModule: StoreModule) {
applicationScope.launch { applicationScope.launch {
featureModules.homeModule.betaVersionUpgradeUseCase.waitUnitReady()
storeModule.credentialsStore().credentials()?.let { storeModule.credentialsStore().credentials()?.let {
featureModules.pushModule.pushTokenRegistrar().registerCurrentToken() featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
} }
runCatching { storeModule.localEchoStore.preload() } runCatching { storeModule.localEchoStore.preload() }
}
applicationScope.launch {
val notificationsUseCase = notificationsModule.notificationsUseCase() val notificationsUseCase = notificationsModule.notificationsUseCase()
notificationsUseCase.listenForNotificationChanges(this) notificationsUseCase.listenForNotificationChanges(this)
} }

View File

@ -6,6 +6,7 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.ExifInterface
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.OpenableColumns import android.provider.OpenableColumns
@ -19,6 +20,7 @@ import app.dapk.st.directory.DirectoryModule
import app.dapk.st.domain.StoreModule import app.dapk.st.domain.StoreModule
import app.dapk.st.engine.MatrixEngine import app.dapk.st.engine.MatrixEngine
import app.dapk.st.firebase.messaging.MessagingModule import app.dapk.st.firebase.messaging.MessagingModule
import app.dapk.st.home.BetaVersionUpgradeUseCase
import app.dapk.st.home.HomeModule import app.dapk.st.home.HomeModule
import app.dapk.st.home.MainActivity import app.dapk.st.home.MainActivity
import app.dapk.st.imageloader.ImageLoaderModule import app.dapk.st.imageloader.ImageLoaderModule
@ -163,7 +165,16 @@ internal class FeatureModules internal constructor(
deviceMeta, deviceMeta,
) )
} }
val homeModule by unsafeLazy { HomeModule(chatEngineModule.engine, storeModule.value, buildMeta) } val homeModule by unsafeLazy {
HomeModule(
chatEngineModule.engine,
storeModule.value,
BetaVersionUpgradeUseCase(
storeModule.value.applicationStore(),
buildMeta,
),
)
}
val settingsModule by unsafeLazy { val settingsModule by unsafeLazy {
SettingsModule( SettingsModule(
chatEngineModule.engine, chatEngineModule.engine,
@ -295,9 +306,14 @@ internal class AndroidImageContentReader(private val contentResolver: ContentRes
cursor.getLong(columnIndex) cursor.getLong(columnIndex)
} ?: throw IllegalArgumentException("Could not process $uri") } ?: throw IllegalArgumentException("Could not process $uri")
val shouldSwapSizes = ExifInterface(contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri")).let {
val orientation = it.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270
}
return ImageContentReader.ImageContent( return ImageContentReader.ImageContent(
height = options.outHeight, height = if (shouldSwapSizes) options.outWidth else options.outHeight,
width = options.outWidth, width = if (shouldSwapSizes) options.outHeight else options.outWidth,
size = fileSize, size = fileSize,
mimeType = options.outMimeType, mimeType = options.outMimeType,
fileName = androidUri.lastPathSegment ?: "file", fileName = androidUri.lastPathSegment ?: "file",

View File

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

View File

@ -23,7 +23,7 @@ fun aRoomOverview(
fun anEncryptedRoomMessageEvent( fun anEncryptedRoomMessageEvent(
eventId: EventId = anEventId(), eventId: EventId = anEventId(),
utcTimestamp: Long = 0L, utcTimestamp: Long = 0L,
content: String = "encrypted-content", content: RichText = RichText.of("encrypted-content"),
author: RoomMember = aRoomMember(), author: RoomMember = aRoomMember(),
meta: MessageMeta = MessageMeta.FromServer, meta: MessageMeta = MessageMeta.FromServer,
edited: Boolean = false, edited: Boolean = false,
@ -47,7 +47,7 @@ fun aRoomReplyMessageEvent(
fun aRoomMessageEvent( fun aRoomMessageEvent(
eventId: EventId = anEventId(), eventId: EventId = anEventId(),
utcTimestamp: Long = 0L, utcTimestamp: Long = 0L,
content: String = "message-content", content: RichText = RichText.of("message-content"),
author: RoomMember = aRoomMember(), author: RoomMember = aRoomMember(),
meta: MessageMeta = MessageMeta.FromServer, meta: MessageMeta = MessageMeta.FromServer,
edited: Boolean = false, edited: Boolean = false,

View 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
}
}

View File

@ -100,11 +100,11 @@ ext.Dependencies.with {
def kotlinVer = "1.7.20" def kotlinVer = "1.7.20"
def sqldelightVer = "1.5.4" def sqldelightVer = "1.5.4"
def composeVer = "1.2.1" def composeVer = "1.2.1"
def ktorVer = "2.1.2" def ktorVer = "2.1.3"
google = new DependenciesContainer() google = new DependenciesContainer()
google.with { google.with {
androidGradlePlugin = "com.android.tools.build:gradle:7.3.0" androidGradlePlugin = "com.android.tools.build:gradle:7.3.1"
androidxComposeUi = "androidx.compose.ui:ui:${composeVer}" androidxComposeUi = "androidx.compose.ui:ui:${composeVer}"
androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}" androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}"
@ -143,7 +143,7 @@ ext.Dependencies.with {
ktorContentNegotiation = "io.ktor:ktor-client-content-negotiation:${ktorVer}" ktorContentNegotiation = "io.ktor:ktor-client-content-negotiation:${ktorVer}"
coil = "io.coil-kt:coil-compose:2.2.2" coil = "io.coil-kt:coil-compose:2.2.2"
accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.25.1" accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.27.0"
junit = "junit:junit:4.13.2" junit = "junit:junit:4.13.2"
kluent = "org.amshove.kluent:kluent:1.72" kluent = "org.amshove.kluent:kluent:1.72"

View File

@ -7,6 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -15,18 +16,26 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import app.dapk.st.core.RichText
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest import coil.request.ImageRequest
private val ENCRYPTED_MESSAGE = RichText(listOf(RichText.Part.Normal("Encrypted message")))
sealed interface BubbleModel { sealed interface BubbleModel {
val event: Event val event: Event
data class Text(val content: String, override val event: Event) : BubbleModel data class Text(val content: RichText, override val event: Event) : BubbleModel
data class Encrypted(override val event: Event) : BubbleModel data class Encrypted(override val event: Event) : BubbleModel
data class Image(val imageContent: ImageContent, val imageRequest: ImageRequest, override val event: Event) : BubbleModel { data class Image(val imageContent: ImageContent, val imageRequest: ImageRequest, override val event: Event) : BubbleModel {
data class ImageContent(val width: Int?, val height: Int?, val url: String) data class ImageContent(val width: Int?, val height: Int?, val url: String)
@ -38,24 +47,29 @@ sealed interface BubbleModel {
data class Event(val authorId: String, val authorName: String, val edited: Boolean, val time: String) data class Event(val authorId: String, val authorName: String, val edited: Boolean, val time: String)
data class Action(
val onLongClick: (BubbleModel) -> Unit,
val onImageClick: (Image) -> Unit,
)
} }
private fun BubbleModel.Reply.isReplyingToSelf() = this.replyingTo.event.authorId == this.reply.event.authorId private fun BubbleModel.Reply.isReplyingToSelf() = this.replyingTo.event.authorId == this.reply.event.authorId
@Composable @Composable
fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, onLongClick: (BubbleModel) -> Unit) { fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, actions: BubbleModel.Action) {
val itemisedLongClick = { onLongClick.invoke(model) } val itemisedLongClick = { actions.onLongClick.invoke(model) }
when (model) { when (model) {
is BubbleModel.Text -> TextBubble(bubble, model, status, itemisedLongClick) is BubbleModel.Text -> TextBubble(bubble, model, status, itemisedLongClick)
is BubbleModel.Encrypted -> EncryptedBubble(bubble, model, status, itemisedLongClick) is BubbleModel.Encrypted -> EncryptedBubble(bubble, model, status, itemisedLongClick)
is BubbleModel.Image -> ImageBubble(bubble, model, status, itemisedLongClick) is BubbleModel.Image -> ImageBubble(bubble, model, status, onItemClick = { actions.onImageClick(model) }, itemisedLongClick)
is BubbleModel.Reply -> ReplyBubble(bubble, model, status, itemisedLongClick) is BubbleModel.Reply -> ReplyBubble(bubble, model, status, itemisedLongClick)
} }
} }
@Composable @Composable
private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) { private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) {
Bubble(bubble, onLongClick) { Bubble(bubble, onItemClick = {}, onLongClick) {
if (bubble.isNotSelf()) { if (bubble.isNotSelf()) {
AuthorName(model.event, bubble) AuthorName(model.event, bubble)
} }
@ -66,12 +80,12 @@ private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Com
@Composable @Composable
private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, status: @Composable () -> Unit, onLongClick: () -> Unit) { private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, status: @Composable () -> Unit, onLongClick: () -> Unit) {
TextBubble(bubble, BubbleModel.Text(content = "Encrypted message", model.event), status, onLongClick) TextBubble(bubble, BubbleModel.Text(content = ENCRYPTED_MESSAGE, model.event), status, onLongClick)
} }
@Composable @Composable
private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onLongClick: () -> Unit) { private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onItemClick: () -> Unit, onLongClick: () -> Unit) {
Bubble(bubble, onLongClick) { Bubble(bubble, onItemClick, onLongClick) {
if (bubble.isNotSelf()) { if (bubble.isNotSelf()) {
AuthorName(model.event, bubble) AuthorName(model.event, bubble)
} }
@ -88,7 +102,7 @@ private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @C
@Composable @Composable
private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) { private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) {
Bubble(bubble, onLongClick) { Bubble(bubble, onItemClick = {}, onLongClick) {
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -111,7 +125,7 @@ private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @C
when (val replyingTo = model.replyingTo) { when (val replyingTo = model.replyingTo) {
is BubbleModel.Text -> { is BubbleModel.Text -> {
Text( Text(
text = replyingTo.content, text = replyingTo.content.toAnnotatedText(),
color = bubble.textColor().copy(alpha = 0.8f), color = bubble.textColor().copy(alpha = 0.8f),
fontSize = 14.sp, fontSize = 14.sp,
modifier = Modifier.wrapContentSize(), modifier = Modifier.wrapContentSize(),
@ -153,7 +167,7 @@ private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @C
when (val message = model.reply) { when (val message = model.reply) {
is BubbleModel.Text -> TextContent(bubble, message.content) is BubbleModel.Text -> TextContent(bubble, message.content)
is BubbleModel.Encrypted -> TextContent(bubble, "Encrypted message") is BubbleModel.Encrypted -> TextContent(bubble, ENCRYPTED_MESSAGE)
is BubbleModel.Image -> { is BubbleModel.Image -> {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Image( Image(
@ -195,7 +209,7 @@ private fun Int.scalerFor(max: Float): Float {
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Composable () -> Unit) { private fun Bubble(bubble: BubbleMeta, onItemClick: () -> Unit, onLongClick: () -> Unit, content: @Composable () -> Unit) {
Box(modifier = Modifier.padding(start = 6.dp)) { Box(modifier = Modifier.padding(start = 6.dp)) {
Box( Box(
Modifier Modifier
@ -203,7 +217,7 @@ private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Compos
.clip(bubble.shape) .clip(bubble.shape)
.background(bubble.background) .background(bubble.background)
.height(IntrinsicSize.Max) .height(IntrinsicSize.Max)
.combinedClickable(onLongClick = onLongClick, onClick = {}), .combinedClickable(onLongClick = onLongClick, onClick = onItemClick),
) { ) {
Column( Column(
Modifier Modifier
@ -233,16 +247,50 @@ private fun Footer(event: BubbleModel.Event, bubble: BubbleMeta, status: @Compos
} }
@Composable @Composable
private fun TextContent(bubble: BubbleMeta, text: String) { private fun TextContent(bubble: BubbleMeta, text: RichText) {
Text( val annotatedText = text.toAnnotatedText()
text = text, val uriHandler = LocalUriHandler.current
color = bubble.textColor(), ClickableText(
fontSize = 15.sp, text = annotatedText,
style = TextStyle(color = bubble.textColor(), fontSize = 15.sp, textAlign = TextAlign.Start),
modifier = Modifier.wrapContentSize(), modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start, onClick = {
annotatedText.getStringAnnotations("url", it, it).firstOrNull()?.let {
uriHandler.openUri(it.item)
}
}
) )
} }
val hyperLinkStyle = SpanStyle(
color = Color(0xff64B5F6),
textDecoration = TextDecoration.Underline
)
val nameStyle = SpanStyle(
color = Color(0xff64B5F6),
)
fun RichText.toAnnotatedText() = buildAnnotatedString {
parts.forEach {
when (it) {
is RichText.Part.Bold -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(it.content) }
is RichText.Part.BoldItalic -> append(it.content)
is RichText.Part.Italic -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(it.content) }
is RichText.Part.Link -> {
pushStringAnnotation("url", annotation = it.url)
withStyle(hyperLinkStyle) { append(it.label) }
pop()
}
is RichText.Part.Normal -> append(it.content)
is RichText.Part.Person -> withStyle(nameStyle) {
append("@${it.displayName.substringBefore(':').removePrefix("@")}")
}
}
}
}
@Composable @Composable
private fun AuthorName(event: BubbleModel.Event, bubble: BubbleMeta) { private fun AuthorName(event: BubbleModel.Event, bubble: BubbleMeta) {
Text( Text(
@ -256,4 +304,4 @@ private fun AuthorName(event: BubbleModel.Event, bubble: BubbleMeta) {
@Composable @Composable
private fun BubbleMeta.textColor(): Color { private fun BubbleMeta.textColor(): Color {
return if (this.isSelf) SmallTalkTheme.extendedColors.onSelfBubble else SmallTalkTheme.extendedColors.onOthersBubble return if (this.isSelf) SmallTalkTheme.extendedColors.onSelfBubble else SmallTalkTheme.extendedColors.onOthersBubble
} }

View File

@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
@ -16,13 +17,14 @@ fun Toolbar(
onNavigate: (() -> Unit)? = null, onNavigate: (() -> Unit)? = null,
title: String? = null, title: String? = null,
offset: (Density.() -> IntOffset)? = null, offset: (Density.() -> IntOffset)? = null,
color: Color = MaterialTheme.colorScheme.background,
actions: @Composable RowScope.() -> Unit = {} actions: @Composable RowScope.() -> Unit = {}
) { ) {
val navigationIcon = foo(onNavigate) val navigationIcon = foo(onNavigate)
TopAppBar( TopAppBar(
modifier = offset?.let { Modifier.offset(it) } ?: Modifier, modifier = offset?.let { Modifier.offset(it) } ?: Modifier,
colors = TopAppBarDefaults.smallTopAppBarColors( colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.background containerColor = color,
), ),
navigationIcon = navigationIcon, navigationIcon = navigationIcon,
title = title?.let { { Text(it, maxLines = 2) } } ?: {}, title = title?.let { { Text(it, maxLines = 2) } } ?: {},

View File

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

View File

@ -3,24 +3,44 @@ package app.dapk.st.home
import app.dapk.st.core.BuildMeta import app.dapk.st.core.BuildMeta
import app.dapk.st.domain.ApplicationPreferences import app.dapk.st.domain.ApplicationPreferences
import app.dapk.st.domain.ApplicationVersion import app.dapk.st.domain.ApplicationVersion
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class BetaVersionUpgradeUseCase( class BetaVersionUpgradeUseCase(
private val applicationPreferences: ApplicationPreferences, private val applicationPreferences: ApplicationPreferences,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) { ) {
private var _continuation: CancellableContinuation<Unit>? = null
fun hasVersionChanged(): Boolean { fun hasVersionChanged(): Boolean {
return runBlocking { return runBlocking { hasChangedVersion() }
val previousVersion = applicationPreferences.readVersion()?.value }
val currentVersion = buildMeta.versionCode
when (previousVersion) { private suspend fun hasChangedVersion(): Boolean {
null -> false val previousVersion = applicationPreferences.readVersion()?.value
else -> currentVersion > previousVersion val currentVersion = buildMeta.versionCode
}.also { return when (previousVersion) {
applicationPreferences.setVersion(ApplicationVersion(currentVersion)) null -> false
else -> currentVersion > previousVersion
}.also {
applicationPreferences.setVersion(ApplicationVersion(currentVersion))
}
}
suspend fun waitUnitReady() {
if (hasChangedVersion()) {
suspendCancellableCoroutine { continuation ->
_continuation = continuation
} }
} }
} }
fun notifyUpgraded() {
_continuation?.resume(Unit)
_continuation = null
}
} }

View File

@ -1,6 +1,5 @@
package app.dapk.st.home package app.dapk.st.home
import app.dapk.st.core.BuildMeta
import app.dapk.st.core.ProvidableModule import app.dapk.st.core.ProvidableModule
import app.dapk.st.directory.DirectoryViewModel import app.dapk.st.directory.DirectoryViewModel
import app.dapk.st.domain.StoreModule import app.dapk.st.domain.StoreModule
@ -11,7 +10,7 @@ import app.dapk.st.profile.ProfileViewModel
class HomeModule( class HomeModule(
private val chatEngine: ChatEngine, private val chatEngine: ChatEngine,
private val storeModule: StoreModule, private val storeModule: StoreModule,
private val buildMeta: BuildMeta, val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
) : ProvidableModule { ) : ProvidableModule {
fun homeViewModel(directory: DirectoryViewModel, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel { fun homeViewModel(directory: DirectoryViewModel, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel {
@ -22,10 +21,7 @@ class HomeModule(
login, login,
profileViewModel, profileViewModel,
storeModule.cacheCleaner(), storeModule.cacheCleaner(),
BetaVersionUpgradeUseCase( betaVersionUpgradeUseCase,
storeModule.applicationStore(),
buildMeta,
),
) )
} }

View File

@ -12,7 +12,6 @@ import app.dapk.st.profile.ProfileViewModel
import app.dapk.st.viewmodel.DapkViewModel import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -87,6 +86,7 @@ class HomeViewModel(
fun clearCache() { fun clearCache() {
viewModelScope.launch { viewModelScope.launch {
cacheCleaner.cleanCache(removeCredentials = false) cacheCleaner.cleanCache(removeCredentials = false)
betaVersionUpgradeUseCase.notifyUpgraded()
_events.emit(HomeEvent.Relaunch) _events.emit(HomeEvent.Relaunch)
} }
} }

View File

@ -1,6 +1,7 @@
package app.dapk.st.messenger package app.dapk.st.messenger
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@ -8,6 +9,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.* import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@ -24,7 +26,11 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -42,6 +48,7 @@ import app.dapk.st.engine.MessageMeta
import app.dapk.st.engine.MessengerState import app.dapk.st.engine.MessengerState
import app.dapk.st.engine.RoomEvent import app.dapk.st.engine.RoomEvent
import app.dapk.st.engine.RoomState import app.dapk.st.engine.RoomState
import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.UserId import app.dapk.st.matrix.common.UserId
import app.dapk.st.messenger.gallery.ImageGalleryActivityPayload import app.dapk.st.messenger.gallery.ImageGalleryActivityPayload
@ -50,6 +57,8 @@ import app.dapk.st.navigator.Navigator
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest import coil.request.ImageRequest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.min
import kotlin.math.roundToInt
@Composable @Composable
internal fun MessengerScreen( internal fun MessengerScreen(
@ -75,7 +84,8 @@ internal fun MessengerScreen(
val messageActions = MessageActions( val messageActions = MessageActions(
onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) }, onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) },
onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) }, onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) },
onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) } onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) },
onImageClick = { viewModel.selectImage(it) }
) )
Column { Column {
@ -84,6 +94,7 @@ internal fun MessengerScreen(
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {}) // DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
// } // }
}) })
when (state.composerState) { when (state.composerState) {
is ComposerState.Text -> { is ComposerState.Text -> {
Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) }) Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) })
@ -105,6 +116,86 @@ internal fun MessengerScreen(
} }
} }
} }
when (state.viewerState) {
null -> {
// do nothing
}
else -> {
Box(Modifier.fillMaxSize().background(Color.Black)) {
BackHandler(onBack = { viewModel.unselectImage() })
ZoomableImage(state.viewerState)
Toolbar(
onNavigate = { viewModel.unselectImage() },
title = state.viewerState.event.event.authorName,
color = Color.Black.copy(alpha = 0.4f),
)
}
}
}
}
@Composable
private fun ZoomableImage(viewerState: ViewerState) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val angle by remember { mutableStateOf(0f) }
var zoom by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
val screenWidth = constraints.maxWidth
val screenHeight = constraints.maxHeight
val renderedSize = remember {
val imageContent = viewerState.event.imageContent
val imageHeight = imageContent.height ?: 120
val heightScaleFactor = screenHeight.toFloat() / imageHeight.toFloat()
val imageWidth = imageContent.width ?: 120
val widthScaleFactor = screenWidth.toFloat() / imageWidth.toFloat()
val scaler = min(heightScaleFactor, widthScaleFactor)
IntSize((imageWidth * scaler).roundToInt(), (imageHeight * scaler).roundToInt())
}
Image(
painter = rememberAsyncImagePainter(model = viewerState.event.imageRequest),
contentDescription = "",
contentScale = ContentScale.Fit,
modifier = Modifier
.graphicsLayer {
scaleX = zoom
scaleY = zoom
rotationZ = angle
translationX = offsetX
translationY = offsetY
}
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(1F..4F)
if (zoom > 1) {
val x = (pan.x * zoom)
val y = (pan.y * zoom)
if (renderedSize.width * zoom > screenWidth) {
val maxZoomedWidthOffset = ((renderedSize.width * zoom) - screenWidth) / 2
offsetX = (offsetX + x).coerceIn(-maxZoomedWidthOffset..maxZoomedWidthOffset)
}
if (renderedSize.height * zoom > screenHeight) {
val maxZoomedHeightOffset = ((renderedSize.height * zoom) - screenHeight) / 2
offsetY = (offsetY + y).coerceIn(-maxZoomedHeightOffset..maxZoomedHeightOffset)
}
} else {
offsetX = 0F
offsetY = 0F
}
}
)
}
.fillMaxSize()
)
}
} }
@Composable @Composable
@ -179,6 +270,11 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio
} }
} }
val bubbleActions = BubbleModel.Action(
onLongClick = { messageActions.onLongClick(it) },
onImageClick = { messageActions.onImageClick(it) }
)
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -200,7 +296,7 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio
onReply = { messageActions.onReply(item) }, onReply = { messageActions.onReply(item) },
) { ) {
val status = @Composable { SendStatus(item) } val status = @Composable { SendStatus(item) }
MessageBubble(this, item.toModel(), status, onLongClick = messageActions.onLongClick) MessageBubble(this, item.toModel(), status, bubbleActions)
} }
} }
} }
@ -210,7 +306,7 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio
private fun RoomEvent.toModel(): BubbleModel { private fun RoomEvent.toModel(): BubbleModel {
val event = BubbleModel.Event(this.author.id.value, this.author.displayName ?: this.author.id.value, this.edited, this.time) val event = BubbleModel.Event(this.author.id.value, this.author.displayName ?: this.author.id.value, this.edited, this.time)
return when (this) { return when (this) {
is RoomEvent.Message -> BubbleModel.Text(this.content, event) is RoomEvent.Message -> BubbleModel.Text(this.content.toApp(), event)
is RoomEvent.Encrypted -> BubbleModel.Encrypted(event) is RoomEvent.Encrypted -> BubbleModel.Encrypted(event)
is RoomEvent.Image -> { is RoomEvent.Image -> {
val imageRequest = LocalImageRequestFactory.current val imageRequest = LocalImageRequestFactory.current
@ -227,6 +323,19 @@ private fun RoomEvent.toModel(): BubbleModel {
} }
} }
private fun RichText.toApp(): app.dapk.st.core.RichText {
return app.dapk.st.core.RichText(this.parts.map {
when (it) {
is RichText.Part.Bold -> app.dapk.st.core.RichText.Part.Bold(it.content)
is RichText.Part.BoldItalic -> app.dapk.st.core.RichText.Part.BoldItalic(it.content)
is RichText.Part.Italic -> app.dapk.st.core.RichText.Part.Italic(it.content)
is RichText.Part.Link -> app.dapk.st.core.RichText.Part.Link(it.url, it.label)
is RichText.Part.Normal -> app.dapk.st.core.RichText.Part.Normal(it.content)
is RichText.Part.Person -> app.dapk.st.core.RichText.Part.Person(it.userId.value)
}
})
}
@Composable @Composable
private fun SendStatus(message: RoomEvent) { private fun SendStatus(message: RoomEvent) {
when (val meta = message.meta) { when (val meta = message.meta) {
@ -269,7 +378,13 @@ private fun SendStatus(message: RoomEvent) {
@OptIn(ExperimentalAnimationApi::class) @OptIn(ExperimentalAnimationApi::class)
@Composable @Composable
private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit, messageActions: MessageActions) { private fun TextComposer(
state: ComposerState.Text,
onTextChange: (String) -> Unit,
onSend: () -> Unit,
onAttach: () -> Unit,
messageActions: MessageActions
) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -320,7 +435,7 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un
) )
Text( Text(
text = it.content, text = it.content.toApp().toAnnotatedText(),
color = SmallTalkTheme.extendedColors.onOthersBubble, color = SmallTalkTheme.extendedColors.onOthersBubble,
fontSize = 14.sp, fontSize = 14.sp,
maxLines = 2, maxLines = 2,
@ -352,6 +467,7 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un
modifier = Modifier.clickable { onAttach() }.wrapContentWidth().align(Alignment.Bottom), modifier = Modifier.clickable { onAttach() }.wrapContentWidth().align(Alignment.Bottom),
imageVector = Icons.Filled.Image, imageVector = Icons.Filled.Image,
contentDescription = "", contentDescription = "",
tint = SmallTalkTheme.extendedColors.onOthersBubble.copy(alpha = 0.5f),
) )
} }
} }
@ -433,4 +549,5 @@ class MessageActions(
val onReply: (RoomEvent) -> Unit, val onReply: (RoomEvent) -> Unit,
val onDismiss: () -> Unit, val onDismiss: () -> Unit,
val onLongClick: (BubbleModel) -> Unit, val onLongClick: (BubbleModel) -> Unit,
val onImageClick: (BubbleModel.Image) -> Unit,
) )

View File

@ -1,6 +1,7 @@
package app.dapk.st.messenger package app.dapk.st.messenger
import app.dapk.st.core.Lce import app.dapk.st.core.Lce
import app.dapk.st.design.components.BubbleModel
import app.dapk.st.engine.MessengerState import app.dapk.st.engine.MessengerState
import app.dapk.st.engine.RoomEvent import app.dapk.st.engine.RoomEvent
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
@ -10,6 +11,11 @@ data class MessengerScreenState(
val roomId: RoomId?, val roomId: RoomId?,
val roomState: Lce<MessengerState>, val roomState: Lce<MessengerState>,
val composerState: ComposerState, val composerState: ComposerState,
val viewerState: ViewerState?
)
data class ViewerState(
val event: BubbleModel.Image,
) )
sealed interface MessengerEvent { sealed interface MessengerEvent {

View File

@ -4,6 +4,7 @@ import android.os.Build
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.dapk.st.core.DeviceMeta import app.dapk.st.core.DeviceMeta
import app.dapk.st.core.Lce import app.dapk.st.core.Lce
import app.dapk.st.core.asString
import app.dapk.st.core.extensions.takeIfContent import app.dapk.st.core.extensions.takeIfContent
import app.dapk.st.design.components.BubbleModel import app.dapk.st.design.components.BubbleModel
import app.dapk.st.domain.application.message.MessageOptionsStore import app.dapk.st.domain.application.message.MessageOptionsStore
@ -11,6 +12,7 @@ import app.dapk.st.engine.ChatEngine
import app.dapk.st.engine.RoomEvent import app.dapk.st.engine.RoomEvent
import app.dapk.st.engine.SendMessage import app.dapk.st.engine.SendMessage
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.asString
import app.dapk.st.navigator.MessageAttachment import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.viewmodel.DapkViewModel import app.dapk.st.viewmodel.DapkViewModel
import app.dapk.st.viewmodel.MutableStateFactory import app.dapk.st.viewmodel.MutableStateFactory
@ -30,7 +32,8 @@ internal class MessengerViewModel(
initialState = MessengerScreenState( initialState = MessengerScreenState(
roomId = null, roomId = null,
roomState = Lce.Loading(), roomState = Lce.Loading(),
composerState = ComposerState.Text(value = "", reply = null) composerState = ComposerState.Text(value = "", reply = null),
viewerState = null,
), ),
factory = factory, factory = factory,
) { ) {
@ -116,7 +119,7 @@ internal class MessengerViewModel(
originalMessage = when (it) { originalMessage = when (it) {
is RoomEvent.Image -> TODO() is RoomEvent.Image -> TODO()
is RoomEvent.Reply -> TODO() is RoomEvent.Reply -> TODO()
is RoomEvent.Message -> it.content is RoomEvent.Message -> it.content.asString()
is RoomEvent.Encrypted -> error("Should never happen") is RoomEvent.Encrypted -> error("Should never happen")
}, },
eventId = it.eventId, eventId = it.eventId,
@ -155,13 +158,25 @@ internal class MessengerViewModel(
} }
} }
fun selectImage(image: BubbleModel.Image) {
updateState {
copy(viewerState = ViewerState(image))
}
}
fun unselectImage() {
updateState {
copy(viewerState = null)
}
}
} }
private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) { private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) {
is BubbleModel.Encrypted -> CopyableResult.NothingToCopy is BubbleModel.Encrypted -> CopyableResult.NothingToCopy
is BubbleModel.Image -> CopyableResult.NothingToCopy is BubbleModel.Image -> CopyableResult.NothingToCopy
is BubbleModel.Reply -> this.reply.findCopyableContent() is BubbleModel.Reply -> this.reply.findCopyableContent()
is BubbleModel.Text -> CopyableResult.Content(CopyToClipboard.Copyable.Text(this.content)) is BubbleModel.Text -> CopyableResult.Content(CopyToClipboard.Copyable.Text(this.content.asString()))
} }
private sealed interface CopyableResult { private sealed interface CopyableResult {

View File

@ -48,7 +48,8 @@ class MessengerViewModelTest {
MessengerScreenState( MessengerScreenState(
roomId = null, roomId = null,
roomState = Lce.Loading(), roomState = Lce.Loading(),
composerState = ComposerState.Text(value = "", reply = null) composerState = ComposerState.Text(value = "", reply = null),
viewerState = null,
) )
) )
} }
@ -114,7 +115,8 @@ class MessengerViewModelTest {
fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState( fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState(
roomId = roomId, roomId = roomId,
roomState = Lce.Content(roomState), roomState = Lce.Content(roomState),
composerState = ComposerState.Text(value = messageContent ?: "", reply = null) composerState = ComposerState.Text(value = messageContent ?: "", reply = null),
viewerState = null,
) )
class FakeCopyToClipboard { class FakeCopyToClipboard {

View File

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

View File

@ -1,5 +1,7 @@
package app.dapk.st.notifications package app.dapk.st.notifications
import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.common.asString
import fixture.aRoomImageMessageEvent import fixture.aRoomImageMessageEvent
import fixture.aRoomMessageEvent import fixture.aRoomMessageEvent
import fixture.aRoomReplyMessageEvent import fixture.aRoomReplyMessageEvent
@ -18,7 +20,7 @@ class RoomEventsToNotifiableMapperTest {
result shouldBeEqualTo listOf( result shouldBeEqualTo listOf(
Notifiable( Notifiable(
content = event.content, content = event.content.asString(),
utcTimestamp = event.utcTimestamp, utcTimestamp = event.utcTimestamp,
author = event.author author = event.author
) )
@ -42,14 +44,14 @@ class RoomEventsToNotifiableMapperTest {
@Test @Test
fun `given reply event with message, when mapping, then uses message for content`() { fun `given reply event with message, when mapping, then uses message for content`() {
val reply = aRoomMessageEvent(utcTimestamp = -1, content = "hello") val reply = aRoomMessageEvent(utcTimestamp = -1, content = RichText.of("hello"))
val event = aRoomReplyMessageEvent(reply, replyingTo = aRoomImageMessageEvent(utcTimestamp = -1)) val event = aRoomReplyMessageEvent(reply, replyingTo = aRoomImageMessageEvent(utcTimestamp = -1))
val result = mapper.map(listOf(event)) val result = mapper.map(listOf(event))
result shouldBeEqualTo listOf( result shouldBeEqualTo listOf(
Notifiable( Notifiable(
content = reply.content, content = reply.content.asString(),
utcTimestamp = event.utcTimestamp, utcTimestamp = event.utcTimestamp,
author = event.author author = event.author
) )

View File

@ -1,9 +1,6 @@
package app.dapk.st.engine package app.dapk.st.engine
import app.dapk.st.matrix.common.CredentialsStore import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.RoomMember
import app.dapk.st.matrix.common.UserId
import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.MessageService
import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.room.RoomService
import app.dapk.st.matrix.sync.RoomStore import app.dapk.st.matrix.sync.RoomStore
@ -68,7 +65,7 @@ internal class DirectoryUseCase(
this.copy( this.copy(
lastMessage = RoomOverview.LastMessage( lastMessage = RoomOverview.LastMessage(
content = when (val message = latestEcho.message) { content = when (val message = latestEcho.message) {
is MessageService.Message.TextMessage -> message.content.body is MessageService.Message.TextMessage -> message.content.body.asString()
is MessageService.Message.ImageMessage -> "\uD83D\uDCF7" is MessageService.Message.ImageMessage -> "\uD83D\uDCF7"
}, },
utcTimestamp = latestEcho.timestampUtc, utcTimestamp = latestEcho.timestampUtc,

View File

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

View File

@ -1,6 +1,7 @@
package app.dapk.st.engine package app.dapk.st.engine
import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.MessageService
import fixture.* import fixture.*
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
@ -54,7 +55,7 @@ class MergeWithLocalEchosUseCaseTest {
private fun createLocalEcho(eventId: EventId, body: String, state: MessageService.LocalEcho.State) = aLocalEcho( private fun createLocalEcho(eventId: EventId, body: String, state: MessageService.LocalEcho.State) = aLocalEcho(
eventId, eventId,
aTextMessage(aTextContent(body)), aTextMessage(aTextContent(RichText.of(body))),
state, state,
) )
} }

View File

@ -1,5 +1,6 @@
package app.dapk.st.engine package app.dapk.st.engine
import app.dapk.st.matrix.common.RichText
import fake.FakeRoomStore import fake.FakeRoomStore
import fixture.NotificationDiffFixtures.aNotificationDiff import fixture.NotificationDiffFixtures.aNotificationDiff
import fixture.aMatrixRoomMessageEvent import fixture.aMatrixRoomMessageEvent
@ -15,8 +16,8 @@ import app.dapk.st.matrix.sync.RoomEvent as MatrixRoomEvent
import app.dapk.st.matrix.sync.RoomOverview as MatrixRoomOverview import app.dapk.st.matrix.sync.RoomOverview as MatrixRoomOverview
private val NO_UNREADS = emptyMap<MatrixRoomOverview, List<MatrixRoomEvent>>() private val NO_UNREADS = emptyMap<MatrixRoomOverview, List<MatrixRoomEvent>>()
private val A_MESSAGE = aMatrixRoomMessageEvent(eventId = anEventId("1"), content = "hello", utcTimestamp = 1000) private val A_MESSAGE = aMatrixRoomMessageEvent(eventId = anEventId("1"), content = RichText.of("hello"), utcTimestamp = 1000)
private val A_MESSAGE_2 = aMatrixRoomMessageEvent(eventId = anEventId("2"), content = "world", utcTimestamp = 2000) private val A_MESSAGE_2 = aMatrixRoomMessageEvent(eventId = anEventId("2"), content = RichText.of("world"), utcTimestamp = 2000)
private val A_ROOM_OVERVIEW = aMatrixRoomOverview(roomId = aRoomId("1")) private val A_ROOM_OVERVIEW = aMatrixRoomOverview(roomId = aRoomId("1"))
private val A_ROOM_OVERVIEW_2 = aMatrixRoomOverview(roomId = aRoomId("2")) private val A_ROOM_OVERVIEW_2 = aMatrixRoomOverview(roomId = aRoomId("2"))

View File

@ -1,5 +1,6 @@
package app.dapk.st.engine package app.dapk.st.engine
import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.common.RoomMember
import app.dapk.st.matrix.common.UserId import app.dapk.st.matrix.common.UserId
@ -24,7 +25,7 @@ import test.delegateReturn
private val A_ROOM_ID = aRoomId() private val A_ROOM_ID = aRoomId()
private val AN_USER_ID = aUserId() private val AN_USER_ID = aUserId()
private val A_ROOM_STATE = aMatrixRoomState() private val A_ROOM_STATE = aMatrixRoomState()
private val A_MERGED_ROOM_STATE = A_ROOM_STATE.copy(events = listOf(aMatrixRoomMessageEvent(content = "a merged event"))) private val A_MERGED_ROOM_STATE = A_ROOM_STATE.copy(events = listOf(aMatrixRoomMessageEvent(content = RichText.of("a merged event"))))
private val A_LOCAL_ECHOS_LIST = listOf(aLocalEcho()) private val A_LOCAL_ECHOS_LIST = listOf(aLocalEcho())
private val A_ROOM_MEMBER = aRoomMember() private val A_ROOM_MEMBER = aRoomMember()

View File

@ -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
}
}

View File

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

View File

@ -60,7 +60,6 @@ internal class SendMessageUseCase(
private suspend fun imageMessageRequest(message: Message.ImageMessage): HttpRequest<ApiSendResponse> { private suspend fun imageMessageRequest(message: Message.ImageMessage): HttpRequest<ApiSendResponse> {
val imageMeta = message.content.meta val imageMeta = message.content.meta
return when (message.sendEncrypted) { return when (message.sendEncrypted) {
true -> { true -> {
val result = mediaEncrypter.encrypt(imageContentReader.inputStream(message.content.uri)) val result = mediaEncrypter.encrypt(imageContentReader.inputStream(message.content.uri))
@ -153,13 +152,13 @@ class ApiMessageMapper {
fun Message.TextMessage.toContents(reply: Message.TextMessage.Reply?) = when (reply) { fun Message.TextMessage.toContents(reply: Message.TextMessage.Reply?) = when (reply) {
null -> ApiMessage.TextMessage.TextContent( null -> ApiMessage.TextMessage.TextContent(
body = this.content.body, body = this.content.body.asString(),
) )
else -> ApiMessage.TextMessage.TextContent( else -> ApiMessage.TextMessage.TextContent(
body = buildReplyFallback(reply.originalMessage, reply.author.id, reply.replyContent), body = buildReplyFallback(reply.originalMessage.asString(), reply.author.id, reply.replyContent),
relatesTo = ApiMessage.RelatesTo(ApiMessage.RelatesTo.InReplyTo(reply.eventId)), relatesTo = ApiMessage.RelatesTo(ApiMessage.RelatesTo.InReplyTo(reply.eventId)),
formattedBody = buildFormattedReply(reply.author.id, reply.originalMessage, reply.replyContent, this.roomId, reply.eventId), formattedBody = buildFormattedReply(reply.author.id, reply.originalMessage.asString(), reply.replyContent, this.roomId, reply.eventId),
format = "org.matrix.custom.html" format = "org.matrix.custom.html"
) )
} }

View File

@ -1,6 +1,7 @@
package fixture package fixture
import app.dapk.st.matrix.common.MessageType import app.dapk.st.matrix.common.MessageType
import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.MessageService
@ -13,6 +14,6 @@ fun aTextMessage(
) = MessageService.Message.TextMessage(content, sendEncrypted, roomId, localId, timestampUtc) ) = MessageService.Message.TextMessage(content, sendEncrypted, roomId, localId, timestampUtc)
fun aTextContent( fun aTextContent(
body: String = "text content body", body: RichText = RichText.of("text content body"),
type: String = MessageType.TEXT.value, type: String = MessageType.TEXT.value,
) = MessageService.Message.Content.TextContent(body, type) ) = MessageService.Message.Content.TextContent(body, type)

View File

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

View File

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

View File

@ -13,6 +13,7 @@ import app.dapk.st.matrix.sync.internal.room.RoomEventsDecrypter
import app.dapk.st.matrix.sync.internal.room.SyncEventDecrypter import app.dapk.st.matrix.sync.internal.room.SyncEventDecrypter
import app.dapk.st.matrix.sync.internal.room.SyncSideEffects import app.dapk.st.matrix.sync.internal.room.SyncSideEffects
import app.dapk.st.matrix.sync.internal.sync.* import app.dapk.st.matrix.sync.internal.sync.*
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@ -41,13 +42,14 @@ internal class DefaultSyncService(
errorTracker: ErrorTracker, errorTracker: ErrorTracker,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
syncConfig: SyncConfig, syncConfig: SyncConfig,
richMessageParser: RichMessageParser,
) : SyncService { ) : SyncService {
private val syncEventsFlow = MutableStateFlow<List<SyncService.SyncEvent>>(emptyList()) private val syncEventsFlow = MutableStateFlow<List<SyncService.SyncEvent>>(emptyList())
private val roomDataSource by lazy { RoomDataSource(roomStore, logger) } private val roomDataSource by lazy { RoomDataSource(roomStore, logger) }
private val eventDecrypter by lazy { SyncEventDecrypter(messageDecrypter, json, logger) } private val eventDecrypter by lazy { SyncEventDecrypter(messageDecrypter, json, logger) }
private val roomEventsDecrypter by lazy { RoomEventsDecrypter(messageDecrypter, json, logger) } private val roomEventsDecrypter by lazy { RoomEventsDecrypter(messageDecrypter, richMessageParser, json, logger) }
private val roomRefresher by lazy { RoomRefresher(roomDataSource, roomEventsDecrypter, logger) } private val roomRefresher by lazy { RoomRefresher(roomDataSource, roomEventsDecrypter, logger) }
private val sync2 by lazy { private val sync2 by lazy {
@ -57,7 +59,7 @@ internal class DefaultSyncService(
roomMembersService, roomMembersService,
roomDataSource, roomDataSource,
TimelineEventsProcessor( TimelineEventsProcessor(
RoomEventCreator(roomMembersService, errorTracker, RoomEventFactory(roomMembersService)), RoomEventCreator(roomMembersService, errorTracker, RoomEventFactory(roomMembersService, richMessageParser), richMessageParser),
roomEventsDecrypter, roomEventsDecrypter,
eventDecrypter, eventDecrypter,
EventLookupUseCase(roomStore) EventLookupUseCase(roomStore)

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import app.dapk.st.matrix.sync.RoomMembersService
import app.dapk.st.matrix.sync.find import app.dapk.st.matrix.sync.find
import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
private typealias Lookup = suspend (EventId) -> LookupResult private typealias Lookup = suspend (EventId) -> LookupResult
@ -19,6 +20,7 @@ internal class RoomEventCreator(
private val roomMembersService: RoomMembersService, private val roomMembersService: RoomMembersService,
private val errorTracker: ErrorTracker, private val errorTracker: ErrorTracker,
private val roomEventFactory: RoomEventFactory, private val roomEventFactory: RoomEventFactory,
private val richMessageParser: RichMessageParser,
) { ) {
suspend fun ApiTimelineEvent.Encrypted.toRoomEvent(roomId: RoomId): RoomEvent? { suspend fun ApiTimelineEvent.Encrypted.toRoomEvent(roomId: RoomId): RoomEvent? {
@ -44,7 +46,7 @@ internal class RoomEventCreator(
} }
suspend fun ApiTimelineEvent.TimelineMessage.toRoomEvent(userCredentials: UserCredentials, roomId: RoomId, lookup: Lookup): RoomEvent? { suspend fun ApiTimelineEvent.TimelineMessage.toRoomEvent(userCredentials: UserCredentials, roomId: RoomId, lookup: Lookup): RoomEvent? {
return TimelineEventMapper(userCredentials, roomId, roomEventFactory).mapToRoomEvent(this, lookup) return TimelineEventMapper(userCredentials, roomId, roomEventFactory, richMessageParser).mapToRoomEvent(this, lookup)
} }
} }
@ -52,6 +54,7 @@ internal class TimelineEventMapper(
private val userCredentials: UserCredentials, private val userCredentials: UserCredentials,
private val roomId: RoomId, private val roomId: RoomId,
private val roomEventFactory: RoomEventFactory, private val roomEventFactory: RoomEventFactory,
private val richMessageParser: RichMessageParser,
) { ) {
suspend fun mapToRoomEvent(event: ApiTimelineEvent.TimelineMessage, lookup: Lookup): RoomEvent? { suspend fun mapToRoomEvent(event: ApiTimelineEvent.TimelineMessage, lookup: Lookup): RoomEvent? {
@ -138,7 +141,7 @@ internal class TimelineEventMapper(
is ApiTimelineEvent.TimelineMessage.Content.Text -> original.toTextMessage( is ApiTimelineEvent.TimelineMessage.Content.Text -> original.toTextMessage(
utcTimestamp = incomingEdit.utcTimestamp, utcTimestamp = incomingEdit.utcTimestamp,
content = incomingEdit.asTextContent().body?.removePrefix(" * ")?.trim() ?: "redacted", content = incomingEdit.asTextContent().let { it.formattedBody ?: it.body }?.removePrefix(" * ") ?: "redacted",
edited = true, edited = true,
) )
@ -148,7 +151,7 @@ internal class TimelineEventMapper(
} }
private fun RoomEvent.Message.edited(edit: ApiTimelineEvent.TimelineMessage) = this.copy( private fun RoomEvent.Message.edited(edit: ApiTimelineEvent.TimelineMessage) = this.copy(
content = edit.asTextContent().body?.removePrefix(" * ")?.trim() ?: "redacted", content = richMessageParser.parse(edit.asTextContent().let { it.formattedBody ?: it.body }?.removePrefix(" * ") ?: "redacted"),
utcTimestamp = edit.utcTimestamp, utcTimestamp = edit.utcTimestamp,
edited = true, edited = true,
) )
@ -156,13 +159,17 @@ internal class TimelineEventMapper(
private suspend fun RoomEventFactory.mapToRoomEvent(source: ApiTimelineEvent.TimelineMessage): RoomEvent { private suspend fun RoomEventFactory.mapToRoomEvent(source: ApiTimelineEvent.TimelineMessage): RoomEvent {
return when (source.content) { return when (source.content) {
is ApiTimelineEvent.TimelineMessage.Content.Image -> source.toImageMessage(userCredentials, roomId) is ApiTimelineEvent.TimelineMessage.Content.Image -> source.toImageMessage(userCredentials, roomId)
is ApiTimelineEvent.TimelineMessage.Content.Text -> source.toTextMessage(roomId) is ApiTimelineEvent.TimelineMessage.Content.Text -> source.toTextMessage(
roomId,
content = source.asTextContent().formattedBody ?: source.content.body ?: "redacted"
)
ApiTimelineEvent.TimelineMessage.Content.Ignored -> throw IllegalStateException() ApiTimelineEvent.TimelineMessage.Content.Ignored -> throw IllegalStateException()
} }
} }
private suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage( private suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage(
content: String = this.asTextContent().formattedBody?.stripTags() ?: this.asTextContent().body ?: "redacted", content: String = this.asTextContent().formattedBody ?: this.asTextContent().body ?: "redacted",
edited: Boolean = false, edited: Boolean = false,
utcTimestamp: Long = this.utcTimestamp, utcTimestamp: Long = this.utcTimestamp,
) = with(roomEventFactory) { toTextMessage(roomId, content, edited, utcTimestamp) } ) = with(roomEventFactory) { toTextMessage(roomId, content, edited, utcTimestamp) }

View File

@ -6,21 +6,23 @@ import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomMembersService import app.dapk.st.matrix.sync.RoomMembersService
import app.dapk.st.matrix.sync.find import app.dapk.st.matrix.sync.find
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
private val UNKNOWN_AUTHOR = RoomMember(id = UserId("unknown"), displayName = null, avatarUrl = null) private val UNKNOWN_AUTHOR = RoomMember(id = UserId("unknown"), displayName = null, avatarUrl = null)
internal class RoomEventFactory( internal class RoomEventFactory(
private val roomMembersService: RoomMembersService private val roomMembersService: RoomMembersService,
private val richMessageParser: RichMessageParser,
) { ) {
suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage( suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage(
roomId: RoomId, roomId: RoomId,
content: String = this.asTextContent().formattedBody?.stripTags() ?: this.asTextContent().body ?: "redacted", content: String,
edited: Boolean = false, edited: Boolean = false,
utcTimestamp: Long = this.utcTimestamp, utcTimestamp: Long = this.utcTimestamp,
) = RoomEvent.Message( ) = RoomEvent.Message(
eventId = this.id, eventId = this.id,
content = content, content = richMessageParser.parse(content),
author = roomMembersService.find(roomId, this.senderId) ?: UNKNOWN_AUTHOR, author = roomMembersService.find(roomId, this.senderId) ?: UNKNOWN_AUTHOR,
utcTimestamp = utcTimestamp, utcTimestamp = utcTimestamp,
meta = MessageMeta.FromServer, meta = MessageMeta.FromServer,
@ -52,37 +54,3 @@ internal class RoomEventFactory(
) )
} }
} }
private fun String.indexOfOrNull(string: String) = this.indexOf(string).takeIf { it != -1 }
fun String.stripTags() = this
.run {
this.indexOfOrNull("</mx-reply>")?.let {
this.substring(it + "</mx-reply>".length)
} ?: this
}
.trim()
.replaceLinks()
.removeTag("p")
.removeTag("em")
.removeTag("strong")
.removeTag("code")
.removeTag("pre")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.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

View File

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

View File

@ -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('\"')
}
}
}

View File

@ -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
}

View File

@ -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")
}

View File

@ -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("&quot;", "\"").replace("&#39;", "'")
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
}

View File

@ -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
}
}

View File

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

View File

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

View File

@ -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! &quot;foo bar&quot;",
expected = RichText(listOf(Normal("Hello world! \"foo bar\"")))
)
@Test
fun `replaces apostrophe entity`() = runParserTest(
input = "Hello world! foo&#39;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) }
}
}

View File

@ -1,9 +1,12 @@
package app.dapk.st.matrix.sync.internal.sync package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.common.asString
import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent import app.dapk.st.matrix.sync.internal.request.ApiEncryptedContent
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
import fake.FakeErrorTracker import fake.FakeErrorTracker
import fake.FakeRoomMembersService import fake.FakeRoomMembersService
import fixture.* import fixture.*
@ -15,11 +18,11 @@ import org.junit.Test
private val A_ROOM_ID = aRoomId() private val A_ROOM_ID = aRoomId()
private val A_SENDER = aRoomMember() private val A_SENDER = aRoomMember()
private val EMPTY_LOOKUP = FakeLookup(LookupResult(apiTimelineEvent = null, roomEvent = null)) private val EMPTY_LOOKUP = FakeLookup(LookupResult(apiTimelineEvent = null, roomEvent = null))
private const val A_TEXT_EVENT_MESSAGE = "a text message" private val A_TEXT_EVENT_MESSAGE = RichText.of("a text message")
private const val A_REPLY_EVENT_MESSAGE = "a reply to another message" private val A_REPLY_EVENT_MESSAGE = RichText.of("a reply to another message")
private val A_TEXT_EVENT = anApiTimelineTextEvent( private val A_TEXT_EVENT = anApiTimelineTextEvent(
senderId = A_SENDER.id, senderId = A_SENDER.id,
content = aTimelineTextEventContent(body = A_TEXT_EVENT_MESSAGE) content = aTimelineTextEventContent(body = A_TEXT_EVENT_MESSAGE.asString())
) )
private val A_TEXT_EVENT_WITHOUT_CONTENT = anApiTimelineTextEvent( private val A_TEXT_EVENT_WITHOUT_CONTENT = anApiTimelineTextEvent(
senderId = A_SENDER.id, senderId = A_SENDER.id,
@ -31,7 +34,11 @@ internal class RoomEventCreatorTest {
private val fakeRoomMembersService = FakeRoomMembersService() private val fakeRoomMembersService = FakeRoomMembersService()
private val roomEventCreator = RoomEventCreator(fakeRoomMembersService, FakeErrorTracker(), RoomEventFactory(fakeRoomMembersService)) private val richMessageParser = RichMessageParser()
private val roomEventCreator = RoomEventCreator(
fakeRoomMembersService, FakeErrorTracker(), RoomEventFactory(fakeRoomMembersService, richMessageParser),
richMessageParser
)
@Test @Test
fun `given Megolm encrypted event then maps to encrypted room message`() = runTest { fun `given Megolm encrypted event then maps to encrypted room message`() = runTest {
@ -89,7 +96,7 @@ internal class RoomEventCreatorTest {
result shouldBeEqualTo aMatrixRoomMessageEvent( result shouldBeEqualTo aMatrixRoomMessageEvent(
eventId = A_TEXT_EVENT_WITHOUT_CONTENT.id, eventId = A_TEXT_EVENT_WITHOUT_CONTENT.id,
utcTimestamp = A_TEXT_EVENT_WITHOUT_CONTENT.utcTimestamp, utcTimestamp = A_TEXT_EVENT_WITHOUT_CONTENT.utcTimestamp,
content = "redacted", content = RichText.of("redacted"),
author = A_SENDER, author = A_SENDER,
) )
} }
@ -97,14 +104,14 @@ internal class RoomEventCreatorTest {
@Test @Test
fun `given edited event with no relation then maps to new room message`() = runTest { fun `given edited event with no relation then maps to new room message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val editEvent = anApiTimelineTextEvent().toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE) val editEvent = anApiTimelineTextEvent().toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE.asString())
val result = with(roomEventCreator) { editEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) } val result = with(roomEventCreator) { editEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) }
result shouldBeEqualTo aMatrixRoomMessageEvent( result shouldBeEqualTo aMatrixRoomMessageEvent(
eventId = editEvent.id, eventId = editEvent.id,
utcTimestamp = editEvent.utcTimestamp, utcTimestamp = editEvent.utcTimestamp,
content = editEvent.asTextContent().body!!, content = RichText.of(editEvent.asTextContent().body!!.trimStart()),
author = A_SENDER, author = A_SENDER,
edited = true edited = true
) )
@ -114,7 +121,7 @@ internal class RoomEventCreatorTest {
fun `given edited event which relates to a timeline event then updates existing message`() = runTest { fun `given edited event which relates to a timeline event then updates existing message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = anApiTimelineTextEvent(utcTimestamp = 0) val originalMessage = anApiTimelineTextEvent(utcTimestamp = 0)
val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE) val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage) val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -132,7 +139,7 @@ internal class RoomEventCreatorTest {
fun `given edited event which relates to a room event then updates existing message`() = runTest { fun `given edited event which relates to a room event then updates existing message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = aMatrixRoomMessageEvent() val originalMessage = aMatrixRoomMessageEvent()
val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE) val editedMessage = originalMessage.toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage) val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -150,7 +157,7 @@ internal class RoomEventCreatorTest {
fun `given edited event which relates to a room reply event then only updates message`() = runTest { fun `given edited event which relates to a room reply event then only updates message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = aRoomReplyMessageEvent(message = aMatrixRoomMessageEvent()) val originalMessage = aRoomReplyMessageEvent(message = aMatrixRoomMessageEvent())
val editedMessage = (originalMessage.message as RoomEvent.Message).toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE) val editedMessage = (originalMessage.message as RoomEvent.Message).toEditEvent(newTimestamp = 1000, messageContent = A_TEXT_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage) val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -170,7 +177,7 @@ internal class RoomEventCreatorTest {
@Test @Test
fun `given edited event is older than related known timeline event then ignores edit`() = runTest { fun `given edited event is older than related known timeline event then ignores edit`() = runTest {
val originalMessage = anApiTimelineTextEvent(utcTimestamp = 1000) val originalMessage = anApiTimelineTextEvent(utcTimestamp = 1000)
val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE) val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage) val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -181,7 +188,7 @@ internal class RoomEventCreatorTest {
@Test @Test
fun `given edited event is older than related room event then ignores edit`() = runTest { fun `given edited event is older than related room event then ignores edit`() = runTest {
val originalMessage = aMatrixRoomMessageEvent(utcTimestamp = 1000) val originalMessage = aMatrixRoomMessageEvent(utcTimestamp = 1000)
val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE) val editedMessage = originalMessage.toEditEvent(newTimestamp = 0, messageContent = A_TEXT_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage) val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } val result = with(roomEventCreator) { editedMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -192,7 +199,7 @@ internal class RoomEventCreatorTest {
@Test @Test
fun `given reply event with no relation then maps to new room message using the full body`() = runTest { fun `given reply event with no relation then maps to new room message using the full body`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val replyEvent = anApiTimelineTextEvent().toReplyEvent(messageContent = A_TEXT_EVENT_MESSAGE) val replyEvent = anApiTimelineTextEvent().toReplyEvent(messageContent = A_TEXT_EVENT_MESSAGE.asString())
println(replyEvent.content) println(replyEvent.content)
val result = with(roomEventCreator) { replyEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) } val result = with(roomEventCreator) { replyEvent.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, EMPTY_LOOKUP) }
@ -200,7 +207,7 @@ internal class RoomEventCreatorTest {
result shouldBeEqualTo aMatrixRoomMessageEvent( result shouldBeEqualTo aMatrixRoomMessageEvent(
eventId = replyEvent.id, eventId = replyEvent.id,
utcTimestamp = replyEvent.utcTimestamp, utcTimestamp = replyEvent.utcTimestamp,
content = replyEvent.asTextContent().body!!, content = RichText.of(replyEvent.asTextContent().body!!),
author = A_SENDER, author = A_SENDER,
) )
} }
@ -209,7 +216,7 @@ internal class RoomEventCreatorTest {
fun `given reply event which relates to a timeline event then maps to reply`() = runTest { fun `given reply event which relates to a timeline event then maps to reply`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = anApiTimelineTextEvent(content = aTimelineTextEventContent(body = "message being replied to")) val originalMessage = anApiTimelineTextEvent(content = aTimelineTextEventContent(body = "message being replied to"))
val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE) val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage) val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -218,7 +225,7 @@ internal class RoomEventCreatorTest {
replyingTo = aMatrixRoomMessageEvent( replyingTo = aMatrixRoomMessageEvent(
eventId = originalMessage.id, eventId = originalMessage.id,
utcTimestamp = originalMessage.utcTimestamp, utcTimestamp = originalMessage.utcTimestamp,
content = originalMessage.asTextContent().body!!, content = RichText.of(originalMessage.asTextContent().body!!),
author = A_SENDER, author = A_SENDER,
), ),
message = aMatrixRoomMessageEvent( message = aMatrixRoomMessageEvent(
@ -234,7 +241,7 @@ internal class RoomEventCreatorTest {
fun `given reply event which relates to a room event then maps to reply`() = runTest { fun `given reply event which relates to a room event then maps to reply`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = aMatrixRoomMessageEvent() val originalMessage = aMatrixRoomMessageEvent()
val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE) val replyMessage = originalMessage.toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage) val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }
@ -254,7 +261,7 @@ internal class RoomEventCreatorTest {
fun `given reply event which relates to another room reply event then maps to reply with the reply's message`() = runTest { fun `given reply event which relates to another room reply event then maps to reply with the reply's message`() = runTest {
fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER) fakeRoomMembersService.givenMember(A_ROOM_ID, A_SENDER.id, A_SENDER)
val originalMessage = aRoomReplyMessageEvent() val originalMessage = aRoomReplyMessageEvent()
val replyMessage = (originalMessage.message as RoomEvent.Message).toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE) val replyMessage = (originalMessage.message as RoomEvent.Message).toReplyEvent(messageContent = A_REPLY_EVENT_MESSAGE.asString())
val lookup = givenLookup(originalMessage) val lookup = givenLookup(originalMessage)
val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) } val result = with(roomEventCreator) { replyMessage.toRoomEvent(A_USER_CREDENTIALS, A_ROOM_ID, lookup) }

View File

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

View File

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

View File

@ -5,8 +5,10 @@ package test
import TestMessage import TestMessage
import TestUser import TestUser
import app.dapk.st.core.extensions.ifNull import app.dapk.st.core.extensions.ifNull
import app.dapk.st.matrix.common.RichText
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.common.RoomMember
import app.dapk.st.matrix.common.asString
import app.dapk.st.matrix.crypto.MatrixMediaDecrypter import app.dapk.st.matrix.crypto.MatrixMediaDecrypter
import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.message.MessageService
import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.message.messageService
@ -138,7 +140,7 @@ class MatrixTestScope(private val testScope: TestScope) {
suspend fun TestMatrix.expectTextMessage(roomId: RoomId, message: TestMessage) { suspend fun TestMatrix.expectTextMessage(roomId: RoomId, message: TestMessage) {
println("expecting ${message.content}") println("expecting ${message.content}")
this.client.syncService().room(roomId) this.client.syncService().room(roomId)
.map { it.events.filterIsInstance<RoomEvent.Message>().map { TestMessage(it.content, it.author) }.firstOrNull() } .map { it.events.filterIsInstance<RoomEvent.Message>().map { TestMessage(it.content.asString(), it.author) }.firstOrNull() }
.assert(message) .assert(message)
} }
@ -170,7 +172,7 @@ class MatrixTestScope(private val testScope: TestScope) {
println("sending $content") println("sending $content")
this.client.messageService().scheduleMessage( this.client.messageService().scheduleMessage(
MessageService.Message.TextMessage( MessageService.Message.TextMessage(
content = MessageService.Message.Content.TextContent(body = content), content = MessageService.Message.Content.TextContent(body = RichText.of(content)),
roomId = roomId, roomId = roomId,
sendEncrypted = isEncrypted, sendEncrypted = isEncrypted,
localId = "local.${UUID.randomUUID()}", localId = "local.${UUID.randomUUID()}",

View File

@ -7,7 +7,7 @@ const config = {
rcBranchesFrom: "main", rcBranchesFrom: "main",
rcMergesTo: "release", rcMergesTo: "release",
packageName: "app.dapk.st", packageName: "app.dapk.st",
matrixRoomId: "!jgNenzNPtSpJLjjsxe:matrix.org" matrixRoomId: "!fuHEgUsoPRBQynkdkF:iswell.cool"
} }
const rcBranchName = "release-candidate" const rcBranchName = "release-candidate"
@ -175,4 +175,4 @@ const readVersionFile = async (github, branch) => {
content: JSON.parse(content), content: JSON.parse(content),
sha: result.data.sha, sha: result.data.sha,
} }
} }

View File

@ -50,7 +50,7 @@ export const release = async (github, version, applicationId, artifacts, config)
owner: config.owner, owner: config.owner,
repo: config.repo, repo: config.repo,
tag_name: version.name, tag_name: version.name,
prerelease: true, prerelease: false,
generate_release_notes: true, generate_release_notes: true,
}) })
@ -219,4 +219,4 @@ const sendReleaseMessage = async (release, config) => {
"msgtype": "m.text" "msgtype": "m.text"
} }
await client.sendEvent(config.matrixRoomId, "m.room.message", content, "") await client.sendEvent(config.matrixRoomId, "m.room.message", content, "")
} }

View File

@ -1,4 +1,4 @@
{ {
"code": 24, "code": 25,
"name": "27/10/2022-V1" "name": "31/10/2022-V1"
} }