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 9fe5472..aeb8ce9 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 @@ -2,10 +2,17 @@ package app.dapk.st.messenger import android.content.res.Configuration import androidx.activity.result.ActivityResultLauncher +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.CircleShape @@ -53,6 +60,7 @@ import app.dapk.st.navigator.Navigator import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import kotlinx.coroutines.launch +import kotlin.math.roundToInt @Composable internal fun MessengerScreen( @@ -83,7 +91,9 @@ internal fun MessengerScreen( }) when (state.composerState) { is ComposerState.Text -> { - Room(state.roomState) + Room(state.roomState, onReply = { + viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) + }) TextComposer( state.composerState, onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) }, @@ -119,11 +129,11 @@ private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLaun } @Composable -private fun ColumnScope.Room(roomStateLce: Lce) { +private fun ColumnScope.Room(roomStateLce: Lce, onReply: (RoomEvent) -> Unit) { when (val state = roomStateLce) { is Lce.Loading -> CenteredLoading() is Lce.Content -> { - RoomContent(state.value.self, state.value.roomState) + RoomContent(state.value.self, state.value.roomState, onReply) val eventBarHeight = 14.dp val typing = state.value.typing when { @@ -166,7 +176,7 @@ private fun ColumnScope.Room(roomStateLce: Lce) { } @Composable -private fun ColumnScope.RoomContent(self: UserId, state: RoomState) { +private fun ColumnScope.RoomContent(self: UserId, state: RoomState, onReply: (RoomEvent) -> Unit) { val listState: LazyListState = rememberLazyListState( initialFirstVisibleItemIndex = 0 ) @@ -192,7 +202,7 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState) { ) { index, item -> val previousEvent = if (index != 0) state.events[index - 1] else null val wasPreviousMessageSameSender = previousEvent?.author?.id == item.author.id - AlignedBubble(item, self, wasPreviousMessageSameSender) { + AlignedBubble(item, self, wasPreviousMessageSameSender, onReply) { when (item) { is RoomEvent.Image -> MessageImage(it as BubbleContent) is Message -> TextBubbleContent(it as BubbleContent) @@ -215,6 +225,7 @@ private fun LazyItemScope.AlignedBubble( message: T, self: UserId, wasPreviousMessageSameSender: Boolean, + onReply: (RoomEvent) -> Unit, content: @Composable (BubbleContent) -> Unit ) { when (message.author.id == self) { @@ -224,7 +235,8 @@ private fun LazyItemScope.AlignedBubble( Bubble( message = message, isNotSelf = false, - wasPreviousMessageSameSender = wasPreviousMessageSameSender + wasPreviousMessageSameSender = wasPreviousMessageSameSender, + onReply = onReply, ) { content(BubbleContent(selfBackgroundShape, SmallTalkTheme.extendedColors.selfBubble, false, message)) } @@ -237,7 +249,8 @@ private fun LazyItemScope.AlignedBubble( Bubble( message = message, isNotSelf = true, - wasPreviousMessageSameSender = wasPreviousMessageSameSender + wasPreviousMessageSameSender = wasPreviousMessageSameSender, + onReply = onReply, ) { content(BubbleContent(othersBackgroundShape, SmallTalkTheme.extendedColors.othersBubble, true, message)) } @@ -332,9 +345,39 @@ private fun Bubble( message: RoomEvent, isNotSelf: Boolean, wasPreviousMessageSameSender: Boolean, + onReply: (RoomEvent) -> Unit, content: @Composable () -> Unit ) { - Row(Modifier.padding(horizontal = 12.dp)) { + + 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(message) + } + } + + coroutineScope.launch { + offsetX.animateTo(targetValue = 0f) + } + } + ) + ) { when { isNotSelf -> { val displayImageSize = 32.dp @@ -583,36 +626,57 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un .fillMaxWidth() .height(IntrinsicSize.Min), verticalAlignment = Alignment.Bottom ) { - Box( + Column( modifier = Modifier - .align(Alignment.Bottom) .weight(1f) .fillMaxHeight() - .background(SmallTalkTheme.extendedColors.othersBubble, RoundedCornerShape(24.dp)), - contentAlignment = Alignment.TopStart, ) { - Box(Modifier.padding(14.dp)) { - if (state.value.isEmpty()) { - Text("Message", color = SmallTalkTheme.extendedColors.onOthersBubble.copy(alpha = 0.5f)) - } - BasicTextField( - modifier = Modifier.fillMaxWidth(), - value = state.value, - onValueChange = { onTextChange(it) }, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - textStyle = LocalTextStyle.current.copy(color = SmallTalkTheme.extendedColors.onOthersBubble), - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true), - decorationBox = { innerField -> - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Box(modifier = Modifier.weight(1f).padding(end = 4.dp)) { innerField() } - Icon( - modifier = Modifier.clickable { onAttach() }.wrapContentWidth().align(Alignment.Bottom), - imageVector = Icons.Filled.Image, - contentDescription = "", - ) - } +// AnimatedVisibility( +// visible = state.reply?.let { it is Message } ?: false, +// enter = slideInVertically { it - 50 }, +// exit = slideOutVertically { it - 50 }, +// ) { +// +// val message = state.reply as Message +// Column( +// modifier = Modifier +// .background(SmallTalkTheme.extendedColors.othersBubble, RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)) +// ) { +// Text(message.author.displayName ?: message.author.id.value) +// Text(message.content) +// Spacer(Modifier.height(50.dp)) +// } +// } + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(SmallTalkTheme.extendedColors.othersBubble, RoundedCornerShape(24.dp)), + contentAlignment = Alignment.TopStart, + ) { + Box(Modifier.padding(14.dp)) { + if (state.value.isEmpty()) { + Text("Message", color = SmallTalkTheme.extendedColors.onOthersBubble.copy(alpha = 0.5f)) } - ) + BasicTextField( + modifier = Modifier.fillMaxWidth(), + value = state.value, + onValueChange = { onTextChange(it) }, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + textStyle = LocalTextStyle.current.copy(color = SmallTalkTheme.extendedColors.onOthersBubble), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true), + decorationBox = { innerField -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Box(modifier = Modifier.weight(1f).padding(end = 4.dp)) { innerField() } + Icon( + modifier = Modifier.clickable { onAttach() }.wrapContentWidth().align(Alignment.Bottom), + imageVector = Icons.Filled.Image, + contentDescription = "", + ) + } + } + ) + } } } Spacer(modifier = Modifier.width(6.dp)) diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt index cf335e6..7c0259c 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt @@ -2,6 +2,7 @@ package app.dapk.st.messenger import app.dapk.st.core.Lce import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.navigator.MessageAttachment data class MessengerScreenState( @@ -16,12 +17,16 @@ sealed interface MessengerEvent { sealed interface ComposerState { + val reply: RoomEvent? + data class Text( val value: String, + override val reply: RoomEvent?, ) : ComposerState data class Attachments( val values: List, + override val reply: RoomEvent?, ) : ComposerState } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt index 94a59f1..c533243 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt @@ -34,7 +34,7 @@ internal class MessengerViewModel( initialState = MessengerScreenState( roomId = null, roomState = Lce.Loading(), - composerState = ComposerState.Text(value = "") + composerState = ComposerState.Text(value = "", reply = null) ), factory = factory, ) { @@ -45,15 +45,40 @@ internal class MessengerViewModel( when (action) { is MessengerAction.OnMessengerVisible -> start(action) MessengerAction.OnMessengerGone -> syncJob?.cancel() - is MessengerAction.ComposerTextUpdate -> updateState { copy(composerState = ComposerState.Text(action.newValue)) } + is MessengerAction.ComposerTextUpdate -> updateState { copy(composerState = ComposerState.Text(action.newValue, composerState.reply)) } MessengerAction.ComposerSendText -> sendMessage() - MessengerAction.ComposerClear -> updateState { copy(composerState = ComposerState.Text("")) } - is MessengerAction.ComposerImageUpdate -> updateState { copy(composerState = ComposerState.Attachments(listOf(action.newValue))) } + MessengerAction.ComposerClear -> resetComposer() + is MessengerAction.ComposerImageUpdate -> updateState { + copy( + composerState = ComposerState.Attachments( + listOf(action.newValue), + composerState.reply + ) + ) + } + + is MessengerAction.ComposerEnterReplyMode -> updateState { + copy( + composerState = when (composerState) { + is ComposerState.Attachments -> composerState.copy(reply = action.replyingTo) + is ComposerState.Text -> composerState.copy(reply = action.replyingTo) + } + ) + } + + MessengerAction.ComposerExitReplyMode -> updateState { + copy( + composerState = when (composerState) { + is ComposerState.Attachments -> composerState.copy(reply = null) + is ComposerState.Text -> composerState.copy(reply = null) + } + ) + } } } private fun start(action: MessengerAction.OnMessengerVisible) { - updateState { copy(roomId = action.roomId, composerState = action.attachments?.let { ComposerState.Attachments(it) } ?: composerState) } + updateState { copy(roomId = action.roomId, composerState = action.attachments?.let { ComposerState.Attachments(it, null) } ?: composerState) } syncJob = viewModelScope.launch { roomStore.markRead(action.roomId) @@ -104,7 +129,7 @@ internal class MessengerViewModel( is ComposerState.Attachments -> { val copy = composerState.copy() - updateState { copy(composerState = ComposerState.Text("")) } + resetComposer() state.roomState.takeIfContent()?.let { content -> val roomState = content.roomState @@ -125,6 +150,10 @@ internal class MessengerViewModel( } } + private fun resetComposer() { + updateState { copy(composerState = ComposerState.Text("", reply = null)) } + } + fun startAttachment() { viewModelScope.launch { _events.emit(MessengerEvent.SelectImageAttachment) @@ -141,6 +170,8 @@ private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roo sealed interface MessengerAction { data class ComposerTextUpdate(val newValue: String) : MessengerAction + data class ComposerEnterReplyMode(val replyingTo: RoomEvent) : MessengerAction + object ComposerExitReplyMode : MessengerAction data class ComposerImageUpdate(val newValue: MessageAttachment) : MessengerAction object ComposerSendText : MessengerAction object ComposerClear : MessengerAction