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: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-java@v2 - uses: actions/setup-java@v3
with: with:
distribution: 'adopt' distribution: 'adopt'
java-version: '11' java-version: '11'

View File

@ -160,6 +160,7 @@ internal class FeatureModules internal constructor(
chatEngineModule.engine, chatEngineModule.engine,
context, context,
storeModule.value.messageStore(), storeModule.value.messageStore(),
deviceMeta,
) )
} }
val homeModule by unsafeLazy { HomeModule(chatEngineModule.engine, storeModule.value, buildMeta) } 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.kluent
dependencies.testImplementation Dependencies.mavenCentral.kotlinTest dependencies.testImplementation Dependencies.mavenCentral.kotlinTest
dependencies.testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.7.20" 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.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.1' dependencies.testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.1'
@ -140,7 +140,7 @@ ext.kotlinTest = { dependencies ->
} }
ext.kotlinFixtures = { 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.kluent
dependencies.testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore dependencies.testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
} }

View File

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

View File

@ -108,12 +108,14 @@ ext.Dependencies.with {
androidxComposeUi = "androidx.compose.ui:ui:${composeVer}" androidxComposeUi = "androidx.compose.ui:ui:${composeVer}"
androidxComposeFoundation = "androidx.compose.foundation:foundation:${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}" 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" 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" 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" accompanistSystemuicontroller = "com.google.accompanist:accompanist-systemuicontroller:0.25.1"
junit = "junit:junit:4.13.2" 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' mockk = 'io.mockk:mockk:1.13.2'
matrixOlm = "org.matrix.android:olm-sdk:3.2.12" 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 { dependencies {
implementation project(':core') implementation project(':core')
implementation platform('com.google.firebase:firebase-bom:29.0.3') implementation platform(Dependencies.google.firebaseBom)
implementation 'com.google.firebase:firebase-crashlytics' 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.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.widget.Toast
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider 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.matrix.common.RoomId
import app.dapk.st.messenger.gallery.GetImageFromGallery import app.dapk.st.messenger.gallery.GetImageFromGallery
import app.dapk.st.navigator.MessageAttachment import app.dapk.st.navigator.MessageAttachment
import coil.request.ImageRequest
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
val LocalDecyptingFetcherFactory = staticCompositionLocalOf<DecryptingFetcherFactory> { throw IllegalAccessError() } val LocalImageRequestFactory = staticCompositionLocalOf<ImageRequest.Builder> { throw IllegalAccessError() }
class MessengerActivity : DapkActivity() { class MessengerActivity : DapkActivity() {
@ -50,7 +50,7 @@ class MessengerActivity : DapkActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val payload = readPayload<MessagerActivityPayload>() 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()) { val galleryLauncher = registerForActivityResult(GetImageFromGallery()) {
it?.let { uri -> it?.let { uri ->
@ -65,10 +65,9 @@ class MessengerActivity : DapkActivity() {
} }
} }
setContent { setContent {
Surface(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize()) {
CompositionLocalProvider(LocalDecyptingFetcherFactory provides factory) { CompositionLocalProvider(LocalImageRequestFactory provides factory) {
MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator, galleryLauncher) MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator, galleryLauncher)
} }
} }

View File

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

View File

@ -1,17 +1,14 @@
package app.dapk.st.messenger package app.dapk.st.messenger
import android.content.res.Configuration import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.* import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@ -28,10 +25,8 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@ -56,7 +51,6 @@ import app.dapk.st.navigator.Navigator
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest import coil.request.ImageRequest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable @Composable
internal fun MessengerScreen( internal fun MessengerScreen(
@ -79,9 +73,10 @@ internal fun MessengerScreen(
else -> null else -> null
} }
val replyActions = ReplyActions( val messageActions = MessageActions(
onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) }, onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) },
onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) } onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) },
onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) }
) )
Column { Column {
@ -92,13 +87,13 @@ internal fun MessengerScreen(
}) })
when (state.composerState) { when (state.composerState) {
is ComposerState.Text -> { 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( TextComposer(
state.composerState, state.composerState,
onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) }, onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) },
onSend = { viewModel.post(MessengerAction.ComposerSendText) }, onSend = { viewModel.post(MessengerAction.ComposerSendText) },
onAttach = { viewModel.startAttachment() }, onAttach = { viewModel.startAttachment() },
replyActions = replyActions, messageActions = messageActions,
) )
} }
@ -115,6 +110,7 @@ internal fun MessengerScreen(
@Composable @Composable
private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>) { private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>) {
val context = LocalContext.current
StartObserving { StartObserving {
this@ObserveEvents.events.launch { this@ObserveEvents.events.launch {
when (it) { when (it) {
@ -123,17 +119,21 @@ private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLaun
galleryLauncher.launch(ImageGalleryActivityPayload(it.roomState.roomOverview.roomName ?: "")) galleryLauncher.launch(ImageGalleryActivityPayload(it.roomState.roomOverview.roomName ?: ""))
} }
} }
is MessengerEvent.Toast -> {
Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show()
}
} }
} }
} }
} }
@Composable @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) { when (val state = roomStateLce) {
is Lce.Loading -> CenteredLoading() is Lce.Loading -> CenteredLoading()
is Lce.Content -> { is Lce.Content -> {
RoomContent(state.value.self, state.value.roomState, replyActions) RoomContent(state.value.self, state.value.roomState, messageActions)
val eventBarHeight = 14.dp val eventBarHeight = 14.dp
val typing = state.value.typing val typing = state.value.typing
when { when {
@ -167,7 +167,7 @@ private fun ColumnScope.Room(roomStateLce: Lce<MessengerState>, replyActions: Re
} }
@Composable @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( val listState: LazyListState = rememberLazyListState(
initialFirstVisibleItemIndex = 0 initialFirstVisibleItemIndex = 0
) )
@ -193,457 +193,41 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, replyActions
) { index, item -> ) { index, item ->
val previousEvent = if (index != 0) state.events[index - 1] else null val previousEvent = if (index != 0) state.events[index - 1] else null
val wasPreviousMessageSameSender = previousEvent?.author?.id == item.author.id 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>( AlignedDraggableContainer(
val shape: RoundedCornerShape, avatar = Avatar(item.author.avatarUrl?.value, item.author.displayName ?: item.author.id.value),
val background: Color, isSelf = self == item.author.id,
val isNotSelf: Boolean, wasPreviousMessageSameSender = wasPreviousMessageSameSender,
val message: T onReply = { messageActions.onReply(item) },
)
@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)
) { ) {
if (content.isNotSelf) { val event = BubbleModel.Event(item.author.id.value, item.author.displayName ?: item.author.id.value, item.edited, item.time)
Text( val status = @Composable { SendStatus(item) }
fontSize = 11.sp, MessageBubble(this, item.toModel(event), status, onLongClick = messageActions.onLongClick)
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)
}
} }
} }
} }
} }
@Composable @Composable
private fun EncryptedBubbleContent(content: BubbleContent<RoomEvent.Encrypted>) { private fun RoomEvent.toModel(event: BubbleModel.Event): BubbleModel = when (this) {
Box(modifier = Modifier.padding(start = 6.dp)) { is RoomEvent.Message -> BubbleModel.Text(this.content, event)
Box( is RoomEvent.Encrypted -> BubbleModel.Encrypted(event)
Modifier is RoomEvent.Image -> {
.padding(4.dp) val imageRequest = LocalImageRequestFactory.current
.clip(content.shape) .memoryCacheKey(this.imageMeta.url)
.background(content.background) .data(this)
.height(IntrinsicSize.Max), .build()
) { val imageContent = BubbleModel.Image.ImageContent(this.imageMeta.width, this.imageMeta.height, this.imageMeta.url)
Column( BubbleModel.Image(imageContent, imageRequest, event)
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,
)
Spacer(modifier = Modifier.height(2.dp)) is RoomEvent.Reply -> {
Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { BubbleModel.Reply(this.replyingTo.toModel(event), this.message.toModel(event))
Text(
fontSize = 9.sp,
text = "${content.message.time}",
textAlign = TextAlign.End,
color = content.textColor(),
modifier = Modifier.wrapContentSize()
)
SendStatus(content.message)
}
}
}
} }
} }
@Composable @Composable
private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) { private fun SendStatus(message: RoomEvent) {
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) {
when (val meta = message.meta) { when (val meta = message.meta) {
MessageMeta.FromServer -> { MessageMeta.FromServer -> {
// last message is self // last message is self
@ -684,7 +268,7 @@ private fun RowScope.SendStatus(message: RoomEvent) {
@OptIn(ExperimentalAnimationApi::class) @OptIn(ExperimentalAnimationApi::class)
@Composable @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( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -713,7 +297,7 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un
) { ) {
if (it is RoomEvent.Message) { if (it is RoomEvent.Message) {
Box(Modifier.padding(12.dp)) { 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( Icon(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
imageVector = Icons.Filled.Close, 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 onReply: (RoomEvent) -> Unit,
val onDismiss: () -> Unit, val onDismiss: () -> Unit,
val onLongClick: (BubbleModel) -> Unit,
) )

View File

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

View File

@ -1,8 +1,11 @@
package app.dapk.st.messenger package app.dapk.st.messenger
import android.os.Build
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.dapk.st.core.DeviceMeta
import app.dapk.st.core.Lce import app.dapk.st.core.Lce
import app.dapk.st.core.extensions.takeIfContent import app.dapk.st.core.extensions.takeIfContent
import app.dapk.st.design.components.BubbleModel
import app.dapk.st.domain.application.message.MessageOptionsStore import app.dapk.st.domain.application.message.MessageOptionsStore
import app.dapk.st.engine.ChatEngine import app.dapk.st.engine.ChatEngine
import app.dapk.st.engine.RoomEvent import app.dapk.st.engine.RoomEvent
@ -20,6 +23,8 @@ import kotlinx.coroutines.launch
internal class MessengerViewModel( internal class MessengerViewModel(
private val chatEngine: ChatEngine, private val chatEngine: ChatEngine,
private val messageOptionsStore: MessageOptionsStore, private val messageOptionsStore: MessageOptionsStore,
private val copyToClipboard: CopyToClipboard,
private val deviceMeta: DeviceMeta,
factory: MutableStateFactory<MessengerScreenState> = defaultStateFactory(), factory: MutableStateFactory<MessengerScreenState> = defaultStateFactory(),
) : DapkViewModel<MessengerScreenState, MessengerEvent>( ) : DapkViewModel<MessengerScreenState, MessengerEvent>(
initialState = MessengerScreenState( 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 { sealed interface MessengerAction {
data class ComposerTextUpdate(val newValue: String) : MessengerAction data class ComposerTextUpdate(val newValue: String) : MessengerAction
data class ComposerEnterReplyMode(val replyingTo: RoomEvent) : MessengerAction data class ComposerEnterReplyMode(val replyingTo: RoomEvent) : MessengerAction
object ComposerExitReplyMode : MessengerAction object ComposerExitReplyMode : MessengerAction
data class CopyToClipboard(val model: BubbleModel) : MessengerAction
data class ComposerImageUpdate(val newValue: MessageAttachment) : MessengerAction data class ComposerImageUpdate(val newValue: MessageAttachment) : MessengerAction
object ComposerSendText : MessengerAction object ComposerSendText : MessengerAction
object ComposerClear : MessengerAction object ComposerClear : MessengerAction

View File

@ -1,6 +1,7 @@
package app.dapk.st.messenger package app.dapk.st.messenger
import ViewModelTest import ViewModelTest
import app.dapk.st.core.DeviceMeta
import app.dapk.st.core.Lce import app.dapk.st.core.Lce
import app.dapk.st.core.extensions.takeIfContent import app.dapk.st.core.extensions.takeIfContent
import app.dapk.st.engine.MessengerState import app.dapk.st.engine.MessengerState
@ -12,6 +13,7 @@ import app.dapk.st.matrix.common.UserId
import fake.FakeChatEngine import fake.FakeChatEngine
import fake.FakeMessageOptionsStore import fake.FakeMessageOptionsStore
import fixture.* import fixture.*
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import org.junit.Test import org.junit.Test
@ -27,10 +29,14 @@ class MessengerViewModelTest {
private val fakeMessageOptionsStore = FakeMessageOptionsStore() private val fakeMessageOptionsStore = FakeMessageOptionsStore()
private val fakeChatEngine = FakeChatEngine() private val fakeChatEngine = FakeChatEngine()
private val fakeCopyToClipboard = FakeCopyToClipboard()
private val deviceMeta = DeviceMeta(26)
private val viewModel = MessengerViewModel( private val viewModel = MessengerViewModel(
fakeChatEngine, fakeChatEngine,
fakeMessageOptionsStore.instance, fakeMessageOptionsStore.instance,
fakeCopyToClipboard.instance,
deviceMeta,
factory = runViewModelTest.testMutableStateFactory(), factory = runViewModelTest.testMutableStateFactory(),
) )
@ -110,3 +116,7 @@ fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, m
roomState = Lce.Content(roomState), roomState = Lce.Content(roomState),
composerState = ComposerState.Text(value = messageContent ?: "", reply = null) 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() -> { missingIds.isNotEmpty() -> {
(memberStore.query(roomId, missingIds).also { membersCache.insert(roomId, it) } + cachedMembers) (memberStore.query(roomId, missingIds).also { membersCache.insert(roomId, it) } + cachedMembers)
} }
else -> 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 { 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 room(roomId: RoomId) = cache.get(roomId)
fun insert(roomId: RoomId, members: List<RoomMember>) { 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) } 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("account_data") val accountData: ApiAccountData? = null,
@SerialName("rooms") val rooms: ApiSyncRooms? = null, @SerialName("rooms") val rooms: ApiSyncRooms? = null,
@SerialName("to_device") val toDevice: ToDevice? = 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("next_batch") val nextBatch: SyncToken,
@SerialName("prev_batch") val prevBatch: SyncToken? = null, @SerialName("prev_batch") val prevBatch: SyncToken? = null,
) )
@ -53,7 +53,7 @@ internal data class ApiInviteEvents(
@Serializable @Serializable
internal data class ApiSyncRoom( internal data class ApiSyncRoom(
@SerialName("timeline") val timeline: ApiSyncRoomTimeline, @SerialName("timeline") val timeline: ApiSyncRoomTimeline,
@SerialName("state") val state: ApiSyncRoomState, @SerialName("state") val state: ApiSyncRoomState? = null,
@SerialName("account_data") val accountData: ApiAccountData? = null, @SerialName("account_data") val accountData: ApiAccountData? = null,
@SerialName("ephemeral") val ephemeral: ApiEphemeral? = null, @SerialName("ephemeral") val ephemeral: ApiEphemeral? = null,
@SerialName("summary") val summary: ApiRoomSummary? = null, @SerialName("summary") val summary: ApiRoomSummary? = null,

View File

@ -28,7 +28,7 @@ internal class SyncSideEffects(
notifyDevicesUpdated.notifyChanges(it, requestToken) 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 decryptedToDeviceEvents = decryptedToDeviceEvents(response)
val roomKeys = handleRoomKeyShares(decryptedToDeviceEvents) val roomKeys = handleRoomKeyShares(decryptedToDeviceEvents)

View File

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

View File

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

View File

@ -50,7 +50,7 @@ internal class RoomProcessor(
} }
private fun ApiSyncRoom.collectMembers(userCredentials: UserCredentials): List<RoomMember> { 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>() .filterIsInstance<ApiTimelineEvent.RoomMember>()
.mapNotNull { .mapNotNull {
when { when {

View File

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

View File

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