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) {
applicationScope.launch {
featureModules.homeModule.betaVersionUpgradeUseCase.waitUnitReady()
storeModule.credentialsStore().credentials()?.let {
featureModules.pushModule.pushTokenRegistrar().registerCurrentToken()
}
runCatching { storeModule.localEchoStore.preload() }
}
applicationScope.launch {
val notificationsUseCase = notificationsModule.notificationsUseCase()
notificationsUseCase.listenForNotificationChanges(this)
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

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.mapToList
import com.squareup.sqldelight.runtime.coroutines.mapToOneNotNull
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
private val json = Json

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

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

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

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.find
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
import app.dapk.st.matrix.sync.internal.sync.message.RichMessageParser
private val UNKNOWN_AUTHOR = RoomMember(id = UserId("unknown"), displayName = null, avatarUrl = null)
internal class RoomEventFactory(
private val roomMembersService: RoomMembersService
private val roomMembersService: RoomMembersService,
private val richMessageParser: RichMessageParser,
) {
suspend fun ApiTimelineEvent.TimelineMessage.toTextMessage(
roomId: RoomId,
content: String = this.asTextContent().formattedBody?.stripTags() ?: this.asTextContent().body ?: "redacted",
content: String,
edited: Boolean = false,
utcTimestamp: Long = this.utcTimestamp,
) = RoomEvent.Message(
eventId = this.id,
content = content,
content = richMessageParser.parse(content),
author = roomMembersService.find(roomId, this.senderId) ?: UNKNOWN_AUTHOR,
utcTimestamp = utcTimestamp,
meta = MessageMeta.FromServer,
@ -52,37 +54,3 @@ internal class RoomEventFactory(
)
}
}
private fun String.indexOfOrNull(string: String) = this.indexOf(string).takeIf { it != -1 }
fun String.stripTags() = this
.run {
this.indexOfOrNull("</mx-reply>")?.let {
this.substring(it + "</mx-reply>".length)
} ?: this
}
.trim()
.replaceLinks()
.removeTag("p")
.removeTag("em")
.removeTag("strong")
.removeTag("code")
.removeTag("pre")
.replace("&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
import app.dapk.st.matrix.common.AvatarUrl
import app.dapk.st.matrix.common.RoomMember
import app.dapk.st.matrix.common.UserCredentials
import app.dapk.st.matrix.common.convertMxUrToUrl
import app.dapk.st.matrix.common.*
import app.dapk.st.matrix.sync.*
import app.dapk.st.matrix.sync.internal.request.ApiSyncRoom
import app.dapk.st.matrix.sync.internal.request.ApiTimelineEvent
@ -79,7 +76,7 @@ internal fun List<RoomEvent>.findLastMessage(): LastMessage? {
private fun RoomEvent.toTextContent(): String = when (this) {
is RoomEvent.Image -> "\uD83D\uDCF7"
is RoomEvent.Message -> this.content
is RoomEvent.Message -> this.content.asString()
is RoomEvent.Reply -> this.message.toTextContent()
is RoomEvent.Encrypted -> "Encrypted message"
}

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ const config = {
rcBranchesFrom: "main",
rcMergesTo: "release",
packageName: "app.dapk.st",
matrixRoomId: "!jgNenzNPtSpJLjjsxe:matrix.org"
matrixRoomId: "!fuHEgUsoPRBQynkdkF:iswell.cool"
}
const rcBranchName = "release-candidate"

View File

@ -50,7 +50,7 @@ export const release = async (github, version, applicationId, artifacts, config)
owner: config.owner,
repo: config.repo,
tag_name: version.name,
prerelease: true,
prerelease: false,
generate_release_notes: true,
})

View File

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