diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/AlignedContainer.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/AlignedContainer.kt index 5eea262..bad52b4 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/AlignedContainer.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/AlignedContainer.kt @@ -32,7 +32,7 @@ data class BubbleMeta( fun BubbleMeta.isNotSelf() = !this.isSelf @Composable -fun LazyItemScope.AlignedContainer( +fun LazyItemScope.AlignedDraggableContainer( avatar: Avatar, isSelf: Boolean, wasPreviousMessageSameSender: Boolean, diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt index 0a61c84..3533232 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt @@ -4,6 +4,7 @@ import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.background 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 @@ -20,72 +21,156 @@ import androidx.compose.ui.unit.sp import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest -data class Event(val authorName: String, val edited: Boolean, val time: String) -data class ImageContent(val width: Int?, val height: Int?, val url: String) +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 Bubble(bubble: BubbleMeta, content: @Composable () -> Unit) { - Box(modifier = Modifier.padding(start = 6.dp)) { - Box( - Modifier - .padding(4.dp) - .clip(bubble.shape) - .background(bubble.background) - .height(IntrinsicSize.Max), - ) { - content() - } +fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit) { + when (model) { + is BubbleModel.Text -> TextBubble(bubble, model, status) + is BubbleModel.Encrypted -> EncryptedBubble(bubble, model, status) + is BubbleModel.Image -> ImageBubble(bubble, model, status) + is BubbleModel.Reply -> ReplyBubble(bubble, model, status) } } @Composable -fun TextBubble(bubble: BubbleMeta, event: Event, textContent: String, status: @Composable () -> Unit) { +private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit) { Bubble(bubble) { - Column( - Modifier - .padding(8.dp) - .width(IntrinsicSize.Max) - .defaultMinSize(minWidth = 50.dp) - ) { - if (bubble.isNotSelf()) { - AuthorName(event, bubble) - } - TextContent(bubble, text = textContent) - Footer(event, bubble, status) + if (bubble.isNotSelf()) { + AuthorName(model.event, bubble) } + TextContent(bubble, text = model.content) + Footer(model.event, bubble, status) } } @Composable -fun EncryptedBubble(bubble: BubbleMeta, event: Event, status: @Composable () -> Unit) { - TextBubble(bubble, event, textContent = "Encrypted message", status) +private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, status: @Composable () -> Unit) { + TextBubble(bubble, BubbleModel.Text(content = "Encrypted message", model.event), status) } @Composable -fun ImageBubble(bubble: BubbleMeta, event: Event, imageContent: ImageContent, status: @Composable () -> Unit, imageRequest: ImageRequest) { +private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit) { + Bubble(bubble) { + 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) { Bubble(bubble) { 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) - .width(IntrinsicSize.Max) - .defaultMinSize(minWidth = 50.dp) ) { - if (bubble.isNotSelf()) { - AuthorName(event, bubble) - } - - Spacer(modifier = Modifier.height(4.dp)) - Image( - modifier = Modifier.size(imageContent.scale(LocalDensity.current, LocalConfiguration.current)), - painter = rememberAsyncImagePainter(model = imageRequest), - contentDescription = null, + val replyName = if (!bubble.isNotSelf() && model.isReplyingToSelf()) "You" else model.replyingTo.event.authorName + Text( + fontSize = 11.sp, + text = replyName, + maxLines = 1, + color = bubble.textColor() ) - Footer(event, bubble, status) + 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 ImageContent.scale(density: Density, configuration: Configuration): DpSize { +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) { @@ -101,14 +186,35 @@ private fun ImageContent.scale(density: Density, configuration: Configuration): } } - private fun Int.scalerFor(max: Float): Float { return max / this } @Composable -private fun Footer(event: Event, bubble: BubbleMeta, status: @Composable () -> Unit) { +private fun Bubble(bubble: BubbleMeta, content: @Composable () -> Unit) { + Box(modifier = Modifier.padding(start = 6.dp)) { + Box( + Modifier + .padding(4.dp) + .clip(bubble.shape) + .background(bubble.background) + .height(IntrinsicSize.Max), + ) { + 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( @@ -134,7 +240,7 @@ private fun TextContent(bubble: BubbleMeta, text: String) { } @Composable -private fun AuthorName(event: Event, bubble: BubbleMeta) { +private fun AuthorName(event: BubbleModel.Event, bubble: BubbleMeta) { Text( fontSize = 11.sp, text = event.authorName, diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index 8d0ae15..a1fa188 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -1,6 +1,5 @@ package app.dapk.st.messenger -import android.content.res.Configuration import androidx.activity.result.ActivityResultLauncher import androidx.compose.animation.* import androidx.compose.animation.core.tween @@ -24,10 +23,8 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.input.KeyboardCapitalization @@ -189,208 +186,40 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, replyActions val previousEvent = if (index != 0) state.events[index - 1] else null val wasPreviousMessageSameSender = previousEvent?.author?.id == item.author.id - AlignedContainer( + AlignedDraggableContainer( avatar = Avatar(item.author.avatarUrl?.value, item.author.displayName ?: item.author.id.value), isSelf = self == item.author.id, wasPreviousMessageSameSender = wasPreviousMessageSameSender, onReply = { replyActions.onReply(item) }, ) { - val event = Event(item.author.displayName ?: item.author.id.value, item.edited, item.time) + val event = BubbleModel.Event(item.author.id.value, item.author.displayName ?: item.author.id.value, item.edited, item.time) val status = @Composable { SendStatus(item) } - when (item) { - is RoomEvent.Image -> { - val context = LocalContext.current - val imageRequest = ImageRequest.Builder(context) - .fetcherFactory(LocalDecyptingFetcherFactory.current) - .memoryCacheKey(item.imageMeta.url) - .data(item) - .build() - ImageBubble(this, event, ImageContent(item.imageMeta.width, item.imageMeta.height, item.imageMeta.url), status, imageRequest) - } - - is RoomEvent.Message -> TextBubble(this, event, item.content, status = status) - is RoomEvent.Reply -> ReplyBubble(this, item) - is RoomEvent.Encrypted -> EncryptedBubble(this, event, status) - } + MessageBubble(this, item.toModel(event), status) } } } } -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) - ) +@Composable +private fun RoomEvent.toModel(event: BubbleModel.Event): BubbleModel = when (this) { + is RoomEvent.Message -> BubbleModel.Text(this.content, event) + is RoomEvent.Encrypted -> BubbleModel.Encrypted(event) + is RoomEvent.Image -> { + val context = LocalContext.current + val imageRequest = ImageRequest.Builder(context) + .fetcherFactory(LocalDecyptingFetcherFactory.current) + .memoryCacheKey(this.imageMeta.url) + .data(this) + .build() + val imageContent = BubbleModel.Image.ImageContent(this.imageMeta.width, this.imageMeta.height, this.imageMeta.url) + BubbleModel.Image(imageContent, imageRequest, event) + } - DpSize( - width = (width * scaler).toDp(), - height = (height * scaler).toDp(), - ) + is RoomEvent.Reply -> { + BubbleModel.Reply(this.replyingTo.toModel(event), this.message.toModel(event)) } } - -private fun Int.scalerFor(max: Float): Float { - return max / this -} - -@Composable -private fun BubbleMeta.textColor(): Color { - return if (this.isSelf) SmallTalkTheme.extendedColors.onSelfBubble else SmallTalkTheme.extendedColors.onOthersBubble -} - -@Composable -private fun ReplyBubble(bubble: BubbleMeta, event: RoomEvent.Reply) { - Box(modifier = Modifier.padding(start = 6.dp)) { - Box( - Modifier - .padding(4.dp) - .clip(bubble.shape) - .background(bubble.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 (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() && event.replyingToSelf) "You" else event.replyingTo.author.displayName - ?: event.replyingTo.author.id.value - Text( - fontSize = 11.sp, - text = replyName, - maxLines = 1, - color = bubble.textColor() - ) - Spacer(modifier = Modifier.height(2.dp)) - when (val replyingTo = event.replyingTo) { - is RoomEvent.Message -> { - Text( - text = replyingTo.content, - color = bubble.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 = bubble.textColor().copy(alpha = 0.8f), - fontSize = 14.sp, - modifier = Modifier.wrapContentSize(), - textAlign = TextAlign.Start, - ) - } - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - if (bubble.isNotSelf()) { - Text( - fontSize = 11.sp, - text = event.message.author.displayName ?: event.message.author.id.value, - maxLines = 1, - color = bubble.textColor() - ) - } - when (val message = event.message) { - is RoomEvent.Message -> { - Text( - text = message.content, - color = bubble.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 = bubble.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 = event.time, - textAlign = TextAlign.End, - color = bubble.textColor(), - modifier = Modifier.wrapContentSize() - ) - SendStatus(event.message) - } - } - } - } -} - - @Composable private fun SendStatus(message: RoomEvent) { when (val meta = message.meta) {