Merge pull request #221 from ouchadam/release-candidate

[Auto] Release Candidate
This commit is contained in:
Adam Brown 2022-10-24 20:15:12 +01:00 committed by GitHub
commit 02e8743b81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 565 additions and 515 deletions

View File

@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v2
- uses: actions/setup-java@v2
- uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: '11'

View File

@ -160,6 +160,7 @@ internal class FeatureModules internal constructor(
chatEngineModule.engine,
context,
storeModule.value.messageStore(),
deviceMeta,
)
}
val homeModule by unsafeLazy { HomeModule(chatEngineModule.engine, storeModule.value, buildMeta) }

View File

@ -132,7 +132,7 @@ ext.kotlinTest = { dependencies ->
dependencies.testImplementation Dependencies.mavenCentral.kluent
dependencies.testImplementation Dependencies.mavenCentral.kotlinTest
dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.7.20"
dependencies.testImplementation 'io.mockk:mockk:1.13.2'
dependencies.testImplementation Dependencies.mavenCentral.mockk
dependencies.testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.1'
@ -140,7 +140,7 @@ ext.kotlinTest = { dependencies ->
}
ext.kotlinFixtures = { dependencies ->
dependencies.testFixturesImplementation 'io.mockk:mockk:1.13.1'
dependencies.testFixturesImplementation Dependencies.mavenCentral.mockk
dependencies.testFixturesImplementation Dependencies.mavenCentral.kluent
dependencies.testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
}

View File

@ -104,6 +104,12 @@ sealed class RoomEvent {
abstract val utcTimestamp: Long
abstract val author: RoomMember
abstract val meta: MessageMeta
abstract val edited: Boolean
val time: String by lazy(mode = LazyThreadSafetyMode.NONE) {
val instant = Instant.ofEpochMilli(utcTimestamp)
ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT)
}
data class Encrypted(
override val eventId: EventId,
@ -112,10 +118,8 @@ sealed class RoomEvent {
override val meta: MessageMeta,
) : RoomEvent() {
val time: String by lazy(mode = LazyThreadSafetyMode.NONE) {
val instant = Instant.ofEpochMilli(utcTimestamp)
ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT)
}
override val edited: Boolean = false
}
data class Message(
@ -124,15 +128,9 @@ sealed class RoomEvent {
val content: String,
override val author: RoomMember,
override val meta: MessageMeta,
val edited: Boolean = false,
override val edited: Boolean = false,
val redacted: Boolean = false,
) : RoomEvent() {
val time: String by lazy(mode = LazyThreadSafetyMode.NONE) {
val instant = Instant.ofEpochMilli(utcTimestamp)
ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT)
}
}
) : RoomEvent()
data class Reply(
val message: RoomEvent,
@ -143,13 +141,9 @@ sealed class RoomEvent {
override val utcTimestamp: Long = message.utcTimestamp
override val author: RoomMember = message.author
override val meta: MessageMeta = message.meta
override val edited: Boolean = message.edited
val replyingToSelf = replyingTo.author == message.author
val time: String by lazy(mode = LazyThreadSafetyMode.NONE) {
val instant = Instant.ofEpochMilli(utcTimestamp)
ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT)
}
}
data class Image(
@ -158,14 +152,9 @@ sealed class RoomEvent {
val imageMeta: ImageMeta,
override val author: RoomMember,
override val meta: MessageMeta,
val edited: Boolean = false,
override val edited: Boolean = false,
) : RoomEvent() {
val time: String by lazy(mode = LazyThreadSafetyMode.NONE) {
val instant = Instant.ofEpochMilli(utcTimestamp)
ZonedDateTime.ofInstant(instant, DEFAULT_ZONE).toLocalTime().format(MESSAGE_TIME_FORMAT)
}
data class ImageMeta(
val width: Int?,
val height: Int?,

View File

@ -108,12 +108,14 @@ ext.Dependencies.with {
androidxComposeUi = "androidx.compose.ui:ui:${composeVer}"
androidxComposeFoundation = "androidx.compose.foundation:foundation:${composeVer}"
androidxComposeMaterial = "androidx.compose.material3:material3:1.0.0-beta03"
androidxComposeMaterial = "androidx.compose.material3:material3:1.0.0-rc01"
androidxComposeIconsExtended = "androidx.compose.material:material-icons-extended:${composeVer}"
androidxActivityCompose = "androidx.activity:activity-compose:1.4.0"
androidxActivityCompose = "androidx.activity:activity-compose:1.6.0"
kotlinCompilerExtensionVersion = "1.3.2"
firebaseCrashlyticsPlugin = "com.google.firebase:firebase-crashlytics-gradle:2.9.1"
firebaseCrashlyticsPlugin = "com.google.firebase:firebase-crashlytics-gradle:2.9.2"
firebaseBom = "com.google.firebase:firebase-bom:31.0.1"
jdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5"
}
@ -144,7 +146,7 @@ ext.Dependencies.with {
accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.25.1"
junit = "junit:junit:4.13.2"
kluent = "org.amshove.kluent:kluent:1.70"
kluent = "org.amshove.kluent:kluent:1.71"
mockk = 'io.mockk:mockk:1.13.2'
matrixOlm = "org.matrix.android:olm-sdk:3.2.12"

View File

@ -0,0 +1,147 @@
package app.dapk.st.design.components
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
private val selfBackgroundShape = RoundedCornerShape(12.dp, 0.dp, 12.dp, 12.dp)
private val othersBackgroundShape = RoundedCornerShape(0.dp, 12.dp, 12.dp, 12.dp)
data class BubbleMeta(
val shape: RoundedCornerShape,
val background: Color,
val isSelf: Boolean,
)
fun BubbleMeta.isNotSelf() = !this.isSelf
@Composable
fun LazyItemScope.AlignedDraggableContainer(
avatar: Avatar,
isSelf: Boolean,
wasPreviousMessageSameSender: Boolean,
onReply: () -> Unit,
content: @Composable BubbleMeta.() -> Unit
) {
val rowWithMeta = @Composable {
DraggableRow(
avatar = avatar,
isSelf = isSelf,
wasPreviousMessageSameSender = wasPreviousMessageSameSender,
onReply = { onReply() }
) {
content(
when (isSelf) {
true -> BubbleMeta(selfBackgroundShape, SmallTalkTheme.extendedColors.selfBubble, isSelf = true)
false -> BubbleMeta(othersBackgroundShape, SmallTalkTheme.extendedColors.othersBubble, isSelf = false)
}
)
}
}
when (isSelf) {
true -> SelfContainer(rowWithMeta)
false -> OtherContainer(rowWithMeta)
}
}
@Composable
private fun LazyItemScope.OtherContainer(content: @Composable () -> Unit) {
Box(modifier = Modifier.Companion.fillParentMaxWidth(0.95f), contentAlignment = Alignment.TopStart) {
content()
}
}
@Composable
private fun LazyItemScope.SelfContainer(content: @Composable () -> Unit) {
Box(modifier = Modifier.Companion.fillParentMaxWidth(), contentAlignment = Alignment.TopEnd) {
Box(modifier = Modifier.Companion.fillParentMaxWidth(0.85f), contentAlignment = Alignment.TopEnd) {
content()
}
}
}
@Composable
private fun DraggableRow(
isSelf: Boolean,
wasPreviousMessageSameSender: Boolean,
onReply: () -> Unit,
avatar: Avatar,
content: @Composable () -> Unit
) {
val screenWidthDp = LocalConfiguration.current.screenWidthDp.dp
val localDensity = LocalDensity.current
val coroutineScope = rememberCoroutineScope()
val offsetX = remember { Animatable(0f) }
Row(
Modifier.padding(horizontal = 12.dp)
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState {
if ((offsetX.value + it) > 0) {
coroutineScope.launch { offsetX.snapTo(offsetX.value + it) }
}
},
onDragStopped = {
with(localDensity) {
if (offsetX.value > (screenWidthDp.toPx() * 0.15)) {
onReply()
}
}
coroutineScope.launch {
offsetX.animateTo(targetValue = 0f)
}
}
)
) {
when (isSelf) {
true -> {
// do nothing
}
false -> SenderAvatar(wasPreviousMessageSameSender, avatar)
}
content()
}
}
@Composable
private fun SenderAvatar(wasPreviousMessageSameSender: Boolean, avatar: Avatar) {
val displayImageSize = 32.dp
when {
wasPreviousMessageSameSender -> {
Spacer(modifier = Modifier.width(displayImageSize))
}
avatar.url == null -> {
MissingAvatarIcon(avatar.name, displayImageSize)
}
else -> {
MessengerUrlIcon(avatar.url, displayImageSize)
}
}
}
data class Avatar(val url: String?, val name: String)

View File

@ -0,0 +1,259 @@
package app.dapk.st.design.components
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
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 coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
sealed interface BubbleModel {
val event: Event
data class Text(val content: String, 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)
}
data class Reply(val replyingTo: BubbleModel, val reply: BubbleModel) : BubbleModel {
override val event = reply.event
}
data class Event(val authorId: String, val authorName: String, val edited: Boolean, val time: String)
}
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) }
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.Reply -> ReplyBubble(bubble, model, status, itemisedLongClick)
}
}
@Composable
private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) {
Bubble(bubble, onLongClick) {
if (bubble.isNotSelf()) {
AuthorName(model.event, bubble)
}
TextContent(bubble, text = model.content)
Footer(model.event, bubble, status)
}
}
@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)
}
@Composable
private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onLongClick: () -> Unit) {
Bubble(bubble, onLongClick) {
if (bubble.isNotSelf()) {
AuthorName(model.event, bubble)
}
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(model.imageContent.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter(model = model.imageRequest),
contentDescription = null,
)
Footer(model.event, bubble, status)
}
}
@Composable
private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) {
Bubble(bubble, onLongClick) {
Column(
Modifier
.fillMaxWidth()
.background(
if (bubble.isNotSelf()) SmallTalkTheme.extendedColors.onOthersBubble.copy(alpha = 0.1f) else SmallTalkTheme.extendedColors.onSelfBubble.copy(
alpha = 0.2f
), RoundedCornerShape(12.dp)
)
.padding(8.dp)
) {
val replyName = if (!bubble.isNotSelf() && model.isReplyingToSelf()) "You" else model.replyingTo.event.authorName
Text(
fontSize = 11.sp,
text = replyName,
maxLines = 1,
color = bubble.textColor()
)
Spacer(modifier = Modifier.height(2.dp))
when (val replyingTo = model.replyingTo) {
is BubbleModel.Text -> {
Text(
text = replyingTo.content,
color = bubble.textColor().copy(alpha = 0.8f),
fontSize = 14.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
is BubbleModel.Encrypted -> {
Text(
text = "Encrypted message",
color = bubble.textColor().copy(alpha = 0.8f),
fontSize = 14.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
is BubbleModel.Image -> {
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(replyingTo.imageContent.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter(replyingTo.imageRequest),
contentDescription = null,
)
Spacer(modifier = Modifier.height(4.dp))
}
is BubbleModel.Reply -> {
// TODO - a reply to a reply
}
}
}
Spacer(modifier = Modifier.height(12.dp))
if (bubble.isNotSelf()) {
AuthorName(model.event, bubble)
}
when (val message = model.reply) {
is BubbleModel.Text -> TextContent(bubble, message.content)
is BubbleModel.Encrypted -> TextContent(bubble, "Encrypted message")
is BubbleModel.Image -> {
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(message.imageContent.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter(model = message.imageRequest),
contentDescription = null,
)
}
is BubbleModel.Reply -> {
// TODO - a reply to a reply
}
}
Footer(model.event, bubble, status)
}
}
private fun BubbleModel.Image.ImageContent.scale(density: Density, configuration: Configuration): DpSize {
val height = this@scale.height ?: 250
val width = this@scale.width ?: 250
return with(density) {
val scaler = minOf(
height.scalerFor(configuration.screenHeightDp.dp.toPx() * 0.5f),
width.scalerFor(configuration.screenWidthDp.dp.toPx() * 0.6f)
)
DpSize(
width = (width * scaler).toDp(),
height = (height * scaler).toDp(),
)
}
}
private fun Int.scalerFor(max: Float): Float {
return max / this
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Composable () -> Unit) {
Box(modifier = Modifier.padding(start = 6.dp)) {
Box(
Modifier
.padding(4.dp)
.clip(bubble.shape)
.background(bubble.background)
.height(IntrinsicSize.Max)
.combinedClickable(onLongClick = onLongClick, onClick = {}),
) {
Column(
Modifier
.padding(8.dp)
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp)
) {
content()
}
}
}
}
@Composable
private fun Footer(event: BubbleModel.Event, bubble: BubbleMeta, status: @Composable () -> Unit) {
Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(top = 2.dp)) {
val editedPrefix = if (event.edited) "(edited) " else null
Text(
fontSize = 9.sp,
text = "${editedPrefix ?: ""}${event.time}",
textAlign = TextAlign.End,
color = bubble.textColor(),
modifier = Modifier.wrapContentSize()
)
status()
}
}
@Composable
private fun TextContent(bubble: BubbleMeta, text: String) {
Text(
text = text,
color = bubble.textColor(),
fontSize = 15.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
@Composable
private fun AuthorName(event: BubbleModel.Event, bubble: BubbleMeta) {
Text(
fontSize = 11.sp,
text = event.authorName,
maxLines = 1,
color = bubble.textColor()
)
}
@Composable
private fun BubbleMeta.textColor(): Color {
return if (this.isSelf) SmallTalkTheme.extendedColors.onSelfBubble else SmallTalkTheme.extendedColors.onOthersBubble
}

View File

@ -2,6 +2,6 @@ applyAndroidLibraryModule(project)
dependencies {
implementation project(':core')
implementation platform('com.google.firebase:firebase-bom:29.0.3')
implementation platform(Dependencies.google.firebaseBom)
implementation 'com.google.firebase:firebase-crashlytics'
}

View File

@ -0,0 +1,22 @@
package app.dapk.st.messenger
import android.content.ClipData
import android.content.ClipboardManager
class CopyToClipboard(private val clipboard: ClipboardManager) {
fun copy(copyable: Copyable) {
clipboard.addPrimaryClipChangedListener { }
when (copyable) {
is Copyable.Text -> {
clipboard.setPrimaryClip(ClipData.newPlainText("", copyable.value))
}
}
}
sealed interface Copyable {
data class Text(val value: String) : Copyable
}
}

View File

@ -1,7 +0,0 @@
package app.dapk.st.messenger
import java.util.*
internal class LocalIdFactory {
fun create() = "local.${UUID.randomUUID()}"
}

View File

@ -5,7 +5,6 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.widget.Toast
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
@ -16,9 +15,10 @@ import app.dapk.st.core.extensions.unsafeLazy
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.messenger.gallery.GetImageFromGallery
import app.dapk.st.navigator.MessageAttachment
import coil.request.ImageRequest
import kotlinx.parcelize.Parcelize
val LocalDecyptingFetcherFactory = staticCompositionLocalOf<DecryptingFetcherFactory> { throw IllegalAccessError() }
val LocalImageRequestFactory = staticCompositionLocalOf<ImageRequest.Builder> { throw IllegalAccessError() }
class MessengerActivity : DapkActivity() {
@ -50,7 +50,7 @@ class MessengerActivity : DapkActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val payload = readPayload<MessagerActivityPayload>()
val factory = module.decryptingFetcherFactory(RoomId(payload.roomId))
val factory = ImageRequest.Builder(applicationContext).fetcherFactory(module.decryptingFetcherFactory(RoomId(payload.roomId)))
val galleryLauncher = registerForActivityResult(GetImageFromGallery()) {
it?.let { uri ->
@ -65,10 +65,9 @@ class MessengerActivity : DapkActivity() {
}
}
setContent {
Surface(Modifier.fillMaxSize()) {
CompositionLocalProvider(LocalDecyptingFetcherFactory provides factory) {
CompositionLocalProvider(LocalImageRequestFactory provides factory) {
MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator, galleryLauncher)
}
}

View File

@ -1,6 +1,8 @@
package app.dapk.st.messenger
import android.content.ClipboardManager
import android.content.Context
import app.dapk.st.core.DeviceMeta
import app.dapk.st.core.ProvidableModule
import app.dapk.st.domain.application.message.MessageOptionsStore
import app.dapk.st.engine.ChatEngine
@ -10,12 +12,15 @@ class MessengerModule(
private val chatEngine: ChatEngine,
private val context: Context,
private val messageOptionsStore: MessageOptionsStore,
private val deviceMeta: DeviceMeta,
) : ProvidableModule {
internal fun messengerViewModel(): MessengerViewModel {
return MessengerViewModel(
chatEngine,
messageOptionsStore,
CopyToClipboard(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager),
deviceMeta,
)
}

View File

@ -1,17 +1,14 @@
package app.dapk.st.messenger
import android.content.res.Configuration
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.animation.*
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
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.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
@ -28,10 +25,8 @@ 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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.KeyboardCapitalization
@ -56,7 +51,6 @@ import app.dapk.st.navigator.Navigator
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
internal fun MessengerScreen(
@ -79,9 +73,10 @@ internal fun MessengerScreen(
else -> null
}
val replyActions = ReplyActions(
val messageActions = MessageActions(
onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) },
onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) }
onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) },
onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) }
)
Column {
@ -92,13 +87,13 @@ internal fun MessengerScreen(
})
when (state.composerState) {
is ComposerState.Text -> {
Room(state.roomState, replyActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) })
Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) })
TextComposer(
state.composerState,
onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) },
onSend = { viewModel.post(MessengerAction.ComposerSendText) },
onAttach = { viewModel.startAttachment() },
replyActions = replyActions,
messageActions = messageActions,
)
}
@ -115,6 +110,7 @@ internal fun MessengerScreen(
@Composable
private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>) {
val context = LocalContext.current
StartObserving {
this@ObserveEvents.events.launch {
when (it) {
@ -123,17 +119,21 @@ private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLaun
galleryLauncher.launch(ImageGalleryActivityPayload(it.roomState.roomOverview.roomName ?: ""))
}
}
is MessengerEvent.Toast -> {
Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show()
}
}
}
}
}
@Composable
private fun ColumnScope.Room(roomStateLce: Lce<MessengerState>, replyActions: ReplyActions, onRetry: () -> Unit) {
private fun ColumnScope.Room(roomStateLce: Lce<MessengerState>, messageActions: MessageActions, onRetry: () -> Unit) {
when (val state = roomStateLce) {
is Lce.Loading -> CenteredLoading()
is Lce.Content -> {
RoomContent(state.value.self, state.value.roomState, replyActions)
RoomContent(state.value.self, state.value.roomState, messageActions)
val eventBarHeight = 14.dp
val typing = state.value.typing
when {
@ -167,7 +167,7 @@ private fun ColumnScope.Room(roomStateLce: Lce<MessengerState>, replyActions: Re
}
@Composable
private fun ColumnScope.RoomContent(self: UserId, state: RoomState, replyActions: ReplyActions) {
private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActions: MessageActions) {
val listState: LazyListState = rememberLazyListState(
initialFirstVisibleItemIndex = 0
)
@ -193,457 +193,41 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, replyActions
) { index, item ->
val previousEvent = if (index != 0) state.events[index - 1] else null
val wasPreviousMessageSameSender = previousEvent?.author?.id == item.author.id
AlignedBubble(item, self, wasPreviousMessageSameSender, replyActions) {
when (item) {
is RoomEvent.Image -> MessageImage(it as BubbleContent<RoomEvent.Image>)
is RoomEvent.Message -> TextBubbleContent(it as BubbleContent<RoomEvent.Message>)
is RoomEvent.Reply -> ReplyBubbleContent(it as BubbleContent<RoomEvent.Reply>)
is RoomEvent.Encrypted -> EncryptedBubbleContent(it as BubbleContent<RoomEvent.Encrypted>)
}
}
}
}
}
private data class BubbleContent<T : RoomEvent>(
val shape: RoundedCornerShape,
val background: Color,
val isNotSelf: Boolean,
val message: T
)
@Composable
private fun <T : RoomEvent> LazyItemScope.AlignedBubble(
message: T,
self: UserId,
wasPreviousMessageSameSender: Boolean,
replyActions: ReplyActions,
content: @Composable (BubbleContent<T>) -> Unit
) {
when (message.author.id == self) {
true -> {
Box(modifier = Modifier.fillParentMaxWidth(), contentAlignment = Alignment.TopEnd) {
Box(modifier = Modifier.fillParentMaxWidth(0.85f), contentAlignment = Alignment.TopEnd) {
Bubble(
message = message,
isNotSelf = false,
wasPreviousMessageSameSender = wasPreviousMessageSameSender,
replyActions = replyActions,
) {
content(BubbleContent(selfBackgroundShape, SmallTalkTheme.extendedColors.selfBubble, false, message))
}
}
}
}
false -> {
Box(modifier = Modifier.fillParentMaxWidth(0.95f), contentAlignment = Alignment.TopStart) {
Bubble(
message = message,
isNotSelf = true,
wasPreviousMessageSameSender = wasPreviousMessageSameSender,
replyActions = replyActions,
) {
content(BubbleContent(othersBackgroundShape, SmallTalkTheme.extendedColors.othersBubble, true, message))
}
}
}
}
}
@Composable
private fun MessageImage(content: BubbleContent<RoomEvent.Image>) {
val context = LocalContext.current
Box(modifier = Modifier.padding(start = 6.dp)) {
Box(
Modifier
.padding(4.dp)
.clip(content.shape)
.background(content.background)
.height(IntrinsicSize.Max),
) {
Column(
Modifier
.padding(8.dp)
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp)
AlignedDraggableContainer(
avatar = Avatar(item.author.avatarUrl?.value, item.author.displayName ?: item.author.id.value),
isSelf = self == item.author.id,
wasPreviousMessageSameSender = wasPreviousMessageSameSender,
onReply = { messageActions.onReply(item) },
) {
if (content.isNotSelf) {
Text(
fontSize = 11.sp,
text = content.message.author.displayName ?: content.message.author.id.value,
maxLines = 1,
color = content.textColor()
)
}
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(content.message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.fetcherFactory(LocalDecyptingFetcherFactory.current)
.memoryCacheKey(content.message.imageMeta.url)
.data(content.message)
.build()
),
contentDescription = null,
)
Spacer(modifier = Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val editedPrefix = if (content.message.edited) "(edited) " else null
Text(
fontSize = 9.sp,
text = "${editedPrefix ?: ""}${content.message.time}",
textAlign = TextAlign.End,
color = content.textColor(),
modifier = Modifier.wrapContentSize()
)
SendStatus(content.message)
}
}
}
}
}
private fun RoomEvent.Image.ImageMeta.scale(density: Density, configuration: Configuration): DpSize {
val height = this@scale.height ?: 250
val width = this@scale.width ?: 250
return with(density) {
val scaler = minOf(
height.scalerFor(configuration.screenHeightDp.dp.toPx() * 0.5f),
width.scalerFor(configuration.screenWidthDp.dp.toPx() * 0.6f)
)
DpSize(
width = (width * scaler).toDp(),
height = (height * scaler).toDp(),
)
}
}
private fun Int.scalerFor(max: Float): Float {
return max / this
}
private val selfBackgroundShape = RoundedCornerShape(12.dp, 0.dp, 12.dp, 12.dp)
private val othersBackgroundShape = RoundedCornerShape(0.dp, 12.dp, 12.dp, 12.dp)
@Composable
private fun Bubble(
message: RoomEvent,
isNotSelf: Boolean,
wasPreviousMessageSameSender: Boolean,
replyActions: ReplyActions,
content: @Composable () -> Unit
) {
val screenWidthDp = LocalConfiguration.current.screenWidthDp.dp
val localDensity = LocalDensity.current
val coroutineScope = rememberCoroutineScope()
val offsetX = remember { Animatable(0f) }
Row(
Modifier.padding(horizontal = 12.dp)
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState {
if ((offsetX.value + it) > 0) {
coroutineScope.launch { offsetX.snapTo(offsetX.value + it) }
}
},
onDragStopped = {
with(localDensity) {
if (offsetX.value > (screenWidthDp.toPx() * 0.15)) {
replyActions.onReply(message)
}
}
coroutineScope.launch {
offsetX.animateTo(targetValue = 0f)
}
}
)
) {
when {
isNotSelf -> {
val displayImageSize = 32.dp
when {
wasPreviousMessageSameSender -> {
Spacer(modifier = Modifier.width(displayImageSize))
}
message.author.avatarUrl == null -> {
MissingAvatarIcon(message.author.displayName ?: message.author.id.value, displayImageSize)
}
else -> {
MessengerUrlIcon(message.author.avatarUrl!!.value, displayImageSize)
}
}
}
}
content()
}
}
@Composable
private fun BubbleContent<*>.textColor(): Color {
return if (this.isNotSelf) SmallTalkTheme.extendedColors.onOthersBubble else SmallTalkTheme.extendedColors.onSelfBubble
}
@Composable
private fun TextBubbleContent(content: BubbleContent<RoomEvent.Message>) {
Box(modifier = Modifier.padding(start = 6.dp)) {
Box(
Modifier
.padding(4.dp)
.clip(content.shape)
.background(content.background)
.height(IntrinsicSize.Max),
) {
Column(
Modifier
.padding(8.dp)
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp)
) {
if (content.isNotSelf) {
Text(
fontSize = 11.sp,
text = content.message.author.displayName ?: content.message.author.id.value,
maxLines = 1,
color = content.textColor()
)
}
Text(
text = content.message.content,
color = content.textColor(),
fontSize = 15.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val editedPrefix = if (content.message.edited) "(edited) " else null
Text(
fontSize = 9.sp,
text = "${editedPrefix ?: ""}${content.message.time}",
textAlign = TextAlign.End,
color = content.textColor(),
modifier = Modifier.wrapContentSize()
)
SendStatus(content.message)
}
val event = BubbleModel.Event(item.author.id.value, item.author.displayName ?: item.author.id.value, item.edited, item.time)
val status = @Composable { SendStatus(item) }
MessageBubble(this, item.toModel(event), status, onLongClick = messageActions.onLongClick)
}
}
}
}
@Composable
private fun EncryptedBubbleContent(content: BubbleContent<RoomEvent.Encrypted>) {
Box(modifier = Modifier.padding(start = 6.dp)) {
Box(
Modifier
.padding(4.dp)
.clip(content.shape)
.background(content.background)
.height(IntrinsicSize.Max),
) {
Column(
Modifier
.padding(8.dp)
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp)
) {
if (content.isNotSelf) {
Text(
fontSize = 11.sp,
text = content.message.author.displayName ?: content.message.author.id.value,
maxLines = 1,
color = content.textColor()
)
}
Text(
text = "Encrypted message",
color = content.textColor(),
fontSize = 15.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
private fun RoomEvent.toModel(event: BubbleModel.Event): BubbleModel = when (this) {
is RoomEvent.Message -> BubbleModel.Text(this.content, event)
is RoomEvent.Encrypted -> BubbleModel.Encrypted(event)
is RoomEvent.Image -> {
val imageRequest = LocalImageRequestFactory.current
.memoryCacheKey(this.imageMeta.url)
.data(this)
.build()
val imageContent = BubbleModel.Image.ImageContent(this.imageMeta.width, this.imageMeta.height, this.imageMeta.url)
BubbleModel.Image(imageContent, imageRequest, event)
}
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
fontSize = 9.sp,
text = "${content.message.time}",
textAlign = TextAlign.End,
color = content.textColor(),
modifier = Modifier.wrapContentSize()
)
SendStatus(content.message)
}
}
}
is RoomEvent.Reply -> {
BubbleModel.Reply(this.replyingTo.toModel(event), this.message.toModel(event))
}
}
@Composable
private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
Box(modifier = Modifier.padding(start = 6.dp)) {
Box(
Modifier
.padding(4.dp)
.clip(content.shape)
.background(content.background)
.height(IntrinsicSize.Max),
) {
Column(
Modifier
.padding(8.dp)
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp)
) {
val context = LocalContext.current
Column(
Modifier
.fillMaxWidth()
.background(
if (content.isNotSelf) SmallTalkTheme.extendedColors.onOthersBubble.copy(alpha = 0.1f) else SmallTalkTheme.extendedColors.onSelfBubble.copy(
alpha = 0.2f
), RoundedCornerShape(12.dp)
)
.padding(8.dp)
) {
val replyName = if (!content.isNotSelf && content.message.replyingToSelf) "You" else content.message.replyingTo.author.displayName
?: content.message.replyingTo.author.id.value
Text(
fontSize = 11.sp,
text = replyName,
maxLines = 1,
color = content.textColor()
)
Spacer(modifier = Modifier.height(2.dp))
when (val replyingTo = content.message.replyingTo) {
is RoomEvent.Message -> {
Text(
text = replyingTo.content,
color = content.textColor().copy(alpha = 0.8f),
fontSize = 14.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
is RoomEvent.Image -> {
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(replyingTo.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.fetcherFactory(LocalDecyptingFetcherFactory.current)
.memoryCacheKey(replyingTo.imageMeta.url)
.data(replyingTo)
.build()
),
contentDescription = null,
)
Spacer(modifier = Modifier.height(4.dp))
}
is RoomEvent.Reply -> {
// TODO - a reply to a reply
}
is RoomEvent.Encrypted -> {
Text(
text = "Encrypted message",
color = content.textColor().copy(alpha = 0.8f),
fontSize = 14.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
if (content.isNotSelf) {
Text(
fontSize = 11.sp,
text = content.message.message.author.displayName ?: content.message.message.author.id.value,
maxLines = 1,
color = content.textColor()
)
}
when (val message = content.message.message) {
is RoomEvent.Message -> {
Text(
text = message.content,
color = content.textColor(),
fontSize = 15.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
is RoomEvent.Image -> {
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.data(message)
.memoryCacheKey(message.imageMeta.url)
.fetcherFactory(LocalDecyptingFetcherFactory.current)
.build()
),
contentDescription = null,
)
Spacer(modifier = Modifier.height(4.dp))
}
is RoomEvent.Reply -> {
// TODO - a reply to a reply
}
is RoomEvent.Encrypted -> {
Text(
text = "Encrypted message",
color = content.textColor(),
fontSize = 15.sp,
modifier = Modifier.wrapContentSize(),
textAlign = TextAlign.Start,
)
}
}
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
fontSize = 9.sp,
text = content.message.time,
textAlign = TextAlign.End,
color = content.textColor(),
modifier = Modifier.wrapContentSize()
)
SendStatus(content.message.message)
}
}
}
}
}
@Composable
private fun RowScope.SendStatus(message: RoomEvent) {
private fun SendStatus(message: RoomEvent) {
when (val meta = message.meta) {
MessageMeta.FromServer -> {
// last message is self
@ -684,7 +268,7 @@ private fun RowScope.SendStatus(message: RoomEvent) {
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit, replyActions: ReplyActions) {
private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit, messageActions: MessageActions) {
Row(
Modifier
.fillMaxWidth()
@ -713,7 +297,7 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un
) {
if (it is RoomEvent.Message) {
Box(Modifier.padding(12.dp)) {
Box(Modifier.padding(8.dp).clickable { replyActions.onDismiss() }.wrapContentWidth().align(Alignment.TopEnd)) {
Box(Modifier.padding(8.dp).clickable { messageActions.onDismiss() }.wrapContentWidth().align(Alignment.TopEnd)) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = Icons.Filled.Close,
@ -844,7 +428,8 @@ private fun AttachmentComposer(state: ComposerState.Attachments, onSend: () -> U
}
}
class ReplyActions(
class MessageActions(
val onReply: (RoomEvent) -> Unit,
val onDismiss: () -> Unit,
val onLongClick: (BubbleModel) -> Unit,
)

View File

@ -14,6 +14,7 @@ data class MessengerScreenState(
sealed interface MessengerEvent {
object SelectImageAttachment : MessengerEvent
data class Toast(val message: String) : MessengerEvent
}
sealed interface ComposerState {

View File

@ -1,8 +1,11 @@
package app.dapk.st.messenger
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.extensions.takeIfContent
import app.dapk.st.design.components.BubbleModel
import app.dapk.st.domain.application.message.MessageOptionsStore
import app.dapk.st.engine.ChatEngine
import app.dapk.st.engine.RoomEvent
@ -20,6 +23,8 @@ import kotlinx.coroutines.launch
internal class MessengerViewModel(
private val chatEngine: ChatEngine,
private val messageOptionsStore: MessageOptionsStore,
private val copyToClipboard: CopyToClipboard,
private val deviceMeta: DeviceMeta,
factory: MutableStateFactory<MessengerScreenState> = defaultStateFactory(),
) : DapkViewModel<MessengerScreenState, MessengerEvent>(
initialState = MessengerScreenState(
@ -65,6 +70,21 @@ internal class MessengerViewModel(
}
)
}
is MessengerAction.CopyToClipboard -> {
viewModelScope.launch {
when (val result = action.model.findCopyableContent()) {
is CopyableResult.Content -> {
copyToClipboard.copy(result.value)
if (deviceMeta.apiVersion <= Build.VERSION_CODES.S_V2) {
_events.emit(MessengerEvent.Toast("Copied to clipboard"))
}
}
CopyableResult.NothingToCopy -> _events.emit(MessengerEvent.Toast("Nothing to copy"))
}
}
}
}
}
@ -137,10 +157,23 @@ internal class MessengerViewModel(
}
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))
}
private sealed interface CopyableResult {
object NothingToCopy : CopyableResult
data class Content(val value: CopyToClipboard.Copyable) : CopyableResult
}
sealed interface MessengerAction {
data class ComposerTextUpdate(val newValue: String) : MessengerAction
data class ComposerEnterReplyMode(val replyingTo: RoomEvent) : MessengerAction
object ComposerExitReplyMode : MessengerAction
data class CopyToClipboard(val model: BubbleModel) : MessengerAction
data class ComposerImageUpdate(val newValue: MessageAttachment) : MessengerAction
object ComposerSendText : MessengerAction
object ComposerClear : MessengerAction

View File

@ -1,6 +1,7 @@
package app.dapk.st.messenger
import ViewModelTest
import app.dapk.st.core.DeviceMeta
import app.dapk.st.core.Lce
import app.dapk.st.core.extensions.takeIfContent
import app.dapk.st.engine.MessengerState
@ -12,6 +13,7 @@ import app.dapk.st.matrix.common.UserId
import fake.FakeChatEngine
import fake.FakeMessageOptionsStore
import fixture.*
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import org.junit.Test
@ -27,10 +29,14 @@ class MessengerViewModelTest {
private val fakeMessageOptionsStore = FakeMessageOptionsStore()
private val fakeChatEngine = FakeChatEngine()
private val fakeCopyToClipboard = FakeCopyToClipboard()
private val deviceMeta = DeviceMeta(26)
private val viewModel = MessengerViewModel(
fakeChatEngine,
fakeMessageOptionsStore.instance,
fakeCopyToClipboard.instance,
deviceMeta,
factory = runViewModelTest.testMutableStateFactory(),
)
@ -110,3 +116,7 @@ fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, m
roomState = Lce.Content(roomState),
composerState = ComposerState.Text(value = messageContent ?: "", reply = null)
)
class FakeCopyToClipboard {
val instance = mockk<CopyToClipboard>()
}

View File

@ -31,6 +31,7 @@ class RoomMembers(private val memberStore: MemberStore, private val membersCache
missingIds.isNotEmpty() -> {
(memberStore.query(roomId, missingIds).also { membersCache.insert(roomId, it) } + cachedMembers)
}
else -> cachedMembers
}
}
@ -44,14 +45,17 @@ class RoomMembers(private val memberStore: MemberStore, private val membersCache
}
}
private const val ROOMS_TO_CACHE_MEMBERS_FOR_SIZE = 12
private const val MEMBERS_TO_CACHE_PER_ROOM = 25
class RoomMembersCache {
private val cache = LRUCache<RoomId, LRUCache<UserId, RoomMember>>(maxSize = 12)
private val cache = LRUCache<RoomId, LRUCache<UserId, RoomMember>>(maxSize = ROOMS_TO_CACHE_MEMBERS_FOR_SIZE)
fun room(roomId: RoomId) = cache.get(roomId)
fun insert(roomId: RoomId, members: List<RoomMember>) {
val map = cache.getOrPut(roomId) { LRUCache(maxSize = 25) }
val map = cache.getOrPut(roomId) { LRUCache(maxSize = MEMBERS_TO_CACHE_PER_ROOM) }
members.forEach { map.put(it.id, it) }
}
}

View File

@ -13,7 +13,7 @@ internal data class ApiSyncResponse(
@SerialName("account_data") val accountData: ApiAccountData? = null,
@SerialName("rooms") val rooms: ApiSyncRooms? = null,
@SerialName("to_device") val toDevice: ToDevice? = null,
@SerialName("device_one_time_keys_count") val oneTimeKeysCount: Map<String, ServerKeyCount>,
@SerialName("device_one_time_keys_count") val oneTimeKeysCount: Map<String, ServerKeyCount>? = null,
@SerialName("next_batch") val nextBatch: SyncToken,
@SerialName("prev_batch") val prevBatch: SyncToken? = null,
)
@ -53,7 +53,7 @@ internal data class ApiInviteEvents(
@Serializable
internal data class ApiSyncRoom(
@SerialName("timeline") val timeline: ApiSyncRoomTimeline,
@SerialName("state") val state: ApiSyncRoomState,
@SerialName("state") val state: ApiSyncRoomState? = null,
@SerialName("account_data") val accountData: ApiAccountData? = null,
@SerialName("ephemeral") val ephemeral: ApiEphemeral? = null,
@SerialName("summary") val summary: ApiRoomSummary? = null,

View File

@ -28,7 +28,7 @@ internal class SyncSideEffects(
notifyDevicesUpdated.notifyChanges(it, requestToken)
}
oneTimeKeyProducer.onServerKeyCount(response.oneTimeKeysCount["signed_curve25519"] ?: ServerKeyCount(0))
oneTimeKeyProducer.onServerKeyCount(response.oneTimeKeysCount?.get("signed_curve25519") ?: ServerKeyCount(0))
val decryptedToDeviceEvents = decryptedToDeviceEvents(response)
val roomKeys = handleRoomKeyShares(decryptedToDeviceEvents)

View File

@ -1,14 +1,14 @@
package app.dapk.st.matrix.sync.internal.sync
import app.dapk.st.matrix.common.RoomId
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.MessageMeta
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
private val UNKNOWN_AUTHOR = RoomMember(id = UserId("unknown"), displayName = null, avatarUrl = null)
internal class RoomEventFactory(
private val roomMembersService: RoomMembersService
) {
@ -21,7 +21,7 @@ internal class RoomEventFactory(
) = RoomEvent.Message(
eventId = this.id,
content = content,
author = roomMembersService.find(roomId, this.senderId)!!,
author = roomMembersService.find(roomId, this.senderId) ?: UNKNOWN_AUTHOR,
utcTimestamp = utcTimestamp,
meta = MessageMeta.FromServer,
edited = edited,
@ -36,7 +36,7 @@ internal class RoomEventFactory(
) = RoomEvent.Image(
eventId = this.id,
imageMeta = imageMeta,
author = roomMembersService.find(roomId, this.senderId)!!,
author = roomMembersService.find(roomId, this.senderId) ?: UNKNOWN_AUTHOR,
utcTimestamp = utcTimestamp,
meta = MessageMeta.FromServer,
edited = edited,

View File

@ -13,7 +13,7 @@ internal class RoomOverviewProcessor(
) {
suspend fun process(roomToProcess: RoomToProcess, previousState: RoomOverview?, lastMessage: LastMessage?): RoomOverview? {
val combinedEvents = roomToProcess.apiSyncRoom.state.stateEvents + roomToProcess.apiSyncRoom.timeline.apiTimelineEvents
val combinedEvents = (roomToProcess.apiSyncRoom.state?.stateEvents.orEmpty()) + roomToProcess.apiSyncRoom.timeline.apiTimelineEvents
val isEncrypted = combinedEvents.any { it is ApiTimelineEvent.Encryption }
val readMarker = roomToProcess.apiSyncRoom.accountData?.events?.filterIsInstance<ApiAccountEvent.FullyRead>()?.firstOrNull()?.content?.eventId
return when (previousState) {

View File

@ -50,7 +50,7 @@ internal class RoomProcessor(
}
private fun ApiSyncRoom.collectMembers(userCredentials: UserCredentials): List<RoomMember> {
return (this.state.stateEvents + this.timeline.apiTimelineEvents)
return (this.state?.stateEvents.orEmpty() + this.timeline.apiTimelineEvents)
.filterIsInstance<ApiTimelineEvent.RoomMember>()
.mapNotNull {
when {

View File

@ -70,7 +70,7 @@ internal class SyncReducer(
}
private fun findRoomsLeft(response: ApiSyncResponse, userCredentials: UserCredentials) = response.rooms?.leave?.filter {
it.value.state.stateEvents.filterIsInstance<ApiTimelineEvent.RoomMember>().any {
it.value.state?.stateEvents.orEmpty().filterIsInstance<ApiTimelineEvent.RoomMember>().any {
it.content.membership.isLeave() && it.senderId == userCredentials.userId
}
}?.map { it.key } ?: emptyList()
@ -91,7 +91,7 @@ internal class SyncReducer(
}
private fun Map<RoomId, ApiSyncRoom>.keepRoomsWithChanges() = this.filter {
it.value.state.stateEvents.isNotEmpty() ||
it.value.state?.stateEvents.orEmpty().isNotEmpty() ||
it.value.timeline.apiTimelineEvents.isNotEmpty() ||
it.value.accountData?.events?.isNotEmpty() == true ||
it.value.ephemeral?.events?.isNotEmpty() == true

View File

@ -1,4 +1,4 @@
{
"code": 22,
"name": "17/10/2022-V1"
"code": 23,
"name": "24/10/2022-V1"
}