Merge pull request #221 from ouchadam/release-candidate
[Auto] Release Candidate
This commit is contained in:
commit
02e8743b81
|
@ -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'
|
||||||
|
|
|
@ -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) }
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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?,
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
|
@ -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
|
||||||
|
}
|
|
@ -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'
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +0,0 @@
|
||||||
package app.dapk.st.messenger
|
|
||||||
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
internal class LocalIdFactory {
|
|
||||||
fun create() = "local.${UUID.randomUUID()}"
|
|
||||||
}
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>()
|
||||||
|
}
|
||||||
|
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{
|
{
|
||||||
"code": 22,
|
"code": 23,
|
||||||
"name": "17/10/2022-V1"
|
"name": "24/10/2022-V1"
|
||||||
}
|
}
|
Loading…
Reference in New Issue