Add long click support on timeline item

This commit is contained in:
ganfra
2022-11-21 12:42:42 +01:00
parent 784b4ec659
commit e293b0a203
12 changed files with 205 additions and 13 deletions

View File

@@ -1,10 +1,11 @@
@file:OptIn(ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
package io.element.android.x.features.messages package io.element.android.x.features.messages
import Avatar import Avatar
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -12,14 +13,14 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.End import androidx.compose.ui.Alignment.Companion.End
import androidx.compose.ui.Alignment.Companion.Start import androidx.compose.ui.Alignment.Companion.Start
@@ -49,6 +50,8 @@ import io.element.android.x.features.messages.model.content.MessagesTimelineItem
import io.element.android.x.features.messages.textcomposer.MessageComposerViewModel import io.element.android.x.features.messages.textcomposer.MessageComposerViewModel
import io.element.android.x.features.messages.textcomposer.MessageComposerViewState import io.element.android.x.features.messages.textcomposer.MessageComposerViewState
import io.element.android.x.textcomposer.TextComposer import io.element.android.x.textcomposer.TextComposer
import kotlinx.coroutines.launch
import timber.log.Timber
private val BUBBLE_RADIUS = 16.dp private val BUBBLE_RADIUS = 16.dp
private val COMPOSER_HEIGHT = 112.dp private val COMPOSER_HEIGHT = 112.dp
@@ -61,6 +64,10 @@ fun MessagesScreen(
val viewModel: MessagesViewModel = mavericksViewModel(argsFactory = { roomId }) val viewModel: MessagesViewModel = mavericksViewModel(argsFactory = { roomId })
val composerViewModel: MessageComposerViewModel = mavericksViewModel(argsFactory = { roomId }) val composerViewModel: MessageComposerViewModel = mavericksViewModel(argsFactory = { roomId })
LogCompositions(tag = "MessagesScreen", msg = "Root") LogCompositions(tag = "MessagesScreen", msg = "Root")
val actionsSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
val coroutineScope = rememberCoroutineScope()
val roomTitle by viewModel.collectAsState(MessagesViewState::roomName) val roomTitle by viewModel.collectAsState(MessagesViewState::roomName)
val roomAvatar by viewModel.collectAsState(MessagesViewState::roomAvatar) val roomAvatar by viewModel.collectAsState(MessagesViewState::roomAvatar)
val timelineItems by viewModel.collectAsState(MessagesViewState::timelineItems) val timelineItems by viewModel.collectAsState(MessagesViewState::timelineItems)
@@ -84,6 +91,21 @@ fun MessagesScreen(
onComposerTextChange = composerViewModel::updateText, onComposerTextChange = composerViewModel::updateText,
composerCanSendMessage = composerCanSendMessage, composerCanSendMessage = composerCanSendMessage,
composerText = composerText, composerText = composerText,
onClick = {
Timber.v("onClick on timeline item: ${it.id}")
},
onLongClick = {
viewModel.computeActionsSheetState(it)
coroutineScope.launch {
actionsSheetState.show()
}
}
)
val itemActionsSheetState by viewModel.collectAsState(prop1 = MessagesViewState::itemActionsSheetState)
TimelineItemActionsScreen(
sheetState = actionsSheetState,
actionsSheetState = itemActionsSheetState(),
onActionClicked = viewModel::handleItemAction
) )
} }
@@ -96,6 +118,8 @@ fun MessagesContent(
onReachedLoadMore: () -> Unit, onReachedLoadMore: () -> Unit,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
onSendMessage: (String) -> Unit, onSendMessage: (String) -> Unit,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
onLongClick: ((MessagesTimelineItemState.MessageEvent)) -> Unit,
composerFullScreen: Boolean, composerFullScreen: Boolean,
onComposerFullScreenChange: () -> Unit, onComposerFullScreenChange: () -> Unit,
onComposerTextChange: (CharSequence) -> Unit, onComposerTextChange: (CharSequence) -> Unit,
@@ -145,7 +169,9 @@ fun MessagesContent(
timelineItems = timelineItems, timelineItems = timelineItems,
hasMoreToLoad = hasMoreToLoad, hasMoreToLoad = hasMoreToLoad,
onReachedLoadMore = onReachedLoadMore, onReachedLoadMore = onReachedLoadMore,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
onClick = onClick,
onLongClick = onLongClick
) )
} }
TextComposer( TextComposer(
@@ -175,6 +201,8 @@ fun TimelineItems(
lazyListState: LazyListState, lazyListState: LazyListState,
timelineItems: List<MessagesTimelineItemState>, timelineItems: List<MessagesTimelineItemState>,
hasMoreToLoad: Boolean, hasMoreToLoad: Boolean,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
onLongClick: ((MessagesTimelineItemState.MessageEvent)) -> Unit,
onReachedLoadMore: () -> Unit, onReachedLoadMore: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -186,7 +214,11 @@ fun TimelineItems(
reverseLayout = true reverseLayout = true
) { ) {
itemsIndexed(timelineItems) { index, timelineItem -> itemsIndexed(timelineItems) { index, timelineItem ->
TimelineItemRow(timelineItem = timelineItem) TimelineItemRow(
timelineItem = timelineItem,
onClick = onClick,
onLongClick = onLongClick
)
} }
if (hasMoreToLoad) { if (hasMoreToLoad) {
item { item {
@@ -199,17 +231,25 @@ fun TimelineItems(
@Composable @Composable
fun TimelineItemRow( fun TimelineItemRow(
timelineItem: MessagesTimelineItemState timelineItem: MessagesTimelineItemState,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
) { ) {
when (timelineItem) { when (timelineItem) {
is MessagesTimelineItemState.Virtual -> return is MessagesTimelineItemState.Virtual -> return
is MessagesTimelineItemState.MessageEvent -> MessageEventRow(messageEvent = timelineItem) is MessagesTimelineItemState.MessageEvent -> MessageEventRow(
messageEvent = timelineItem,
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) }
)
} }
} }
@Composable @Composable
fun MessageEventRow( fun MessageEventRow(
messageEvent: MessagesTimelineItemState.MessageEvent, messageEvent: MessagesTimelineItemState.MessageEvent,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val (parentAlignment, contentAlignment) = if (messageEvent.isMine) { val (parentAlignment, contentAlignment) = if (messageEvent.isMine) {
@@ -241,6 +281,8 @@ fun MessageEventRow(
MessageEventBubble( MessageEventBubble(
groupPosition = messageEvent.groupPosition, groupPosition = messageEvent.groupPosition,
isMine = messageEvent.isMine, isMine = messageEvent.isMine,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier modifier = Modifier
.zIndex(-1f) .zIndex(-1f)
) { ) {
@@ -304,11 +346,14 @@ private fun MessageSenderInformation(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun MessageEventBubble( fun MessageEventBubble(
groupPosition: MessagesItemGroupPosition, groupPosition: MessagesItemGroupPosition,
isMine: Boolean, isMine: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
fun bubbleShape(): Shape { fun bubbleShape(): Shape {
@@ -360,8 +405,9 @@ fun MessageEventBubble(
.widthIn(min = 80.dp) .widthIn(min = 80.dp)
.offsetForItem() .offsetForItem()
.clip(bubbleShape) .clip(bubbleShape)
.clickable( .combinedClickable(
onClick = { }, onClick = onClick,
onLongClick = onLongClick,
indication = rememberRipple(), indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
), ),

View File

@@ -5,6 +5,9 @@ import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.ViewModelContext import com.airbnb.mvrx.ViewModelContext
import io.element.android.x.designsystem.components.avatar.AvatarData import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.designsystem.components.avatar.AvatarSize import io.element.android.x.designsystem.components.avatar.AvatarSize
import io.element.android.x.features.messages.model.MessagesItemAction
import io.element.android.x.features.messages.model.MessagesItemActionsSheetState
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.model.MessagesViewState import io.element.android.x.features.messages.model.MessagesViewState
import io.element.android.x.matrix.MatrixClient import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.MatrixInstance import io.element.android.x.matrix.MatrixInstance
@@ -16,6 +19,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
private const val PAGINATION_COUNT = 50 private const val PAGINATION_COUNT = 50
@@ -66,6 +70,28 @@ class MessagesViewModel(
} }
} }
fun handleItemAction(action: MessagesItemAction) {
viewModelScope.launch(Dispatchers.Default) {
val currentState = awaitState()
Timber.v("Handle $action for ${currentState.itemActionsSheetState}")
}
}
fun computeActionsSheetState(messagesTimelineItemState: MessagesTimelineItemState.MessageEvent) {
suspend {
val actions = listOf(
MessagesItemAction.Forward,
MessagesItemAction.Copy,
)
MessagesItemActionsSheetState(
targetItem = messagesTimelineItemState,
actions = actions
)
}.execute(Dispatchers.Default) {
copy(itemActionsSheetState = it)
}
}
private fun handleInit() { private fun handleInit() {
timeline.initialize() timeline.initialize()
room.syncUpdateFlow() room.syncUpdateFlow()

View File

@@ -0,0 +1,59 @@
@file:OptIn(ExperimentalMaterialApi::class)
package io.element.android.x.features.messages.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.x.designsystem.components.VectorIcon
import io.element.android.x.features.messages.model.MessagesItemAction
import io.element.android.x.features.messages.model.MessagesItemActionsSheetState
@Composable
fun TimelineItemActionsScreen(
sheetState: ModalBottomSheetState,
actionsSheetState: MessagesItemActionsSheetState?,
onActionClicked: (MessagesItemAction) -> Unit,
modifier: Modifier = Modifier
) {
ModalBottomSheetLayout(
modifier = modifier,
sheetState = sheetState,
sheetContent = {
SheetContent(
actionsSheetState = actionsSheetState,
onActionClicked = onActionClicked
)
}
) {}
}
@Composable
private fun SheetContent(
actionsSheetState: MessagesItemActionsSheetState?,
onActionClicked: (MessagesItemAction) -> Unit,
) {
if (actionsSheetState == null || actionsSheetState.actions.isEmpty()) {
// Crashes if sheetContent size is zero
Box(modifier = Modifier.size(1.dp))
return
}
LazyColumn {
items(actionsSheetState.actions) {
ListItem(
modifier = Modifier.clickable {
onActionClicked(it)
},
text = { Text(it.title) },
icon = { VectorIcon(it.icon) }
)
}
}
}

View File

@@ -0,0 +1,11 @@
package io.element.android.x.features.messages.model
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Stable
import io.element.android.x.designsystem.VectorIcons
@Stable
sealed class MessagesItemAction(val title: String, @DrawableRes val icon: Int) {
object Forward : MessagesItemAction("Forward", VectorIcons.ArrowForward)
object Copy : MessagesItemAction("Copy", VectorIcons.Copy)
}

View File

@@ -0,0 +1,9 @@
package io.element.android.x.features.messages.model
import androidx.compose.runtime.Stable
@Stable
data class MessagesItemActionsSheetState(
val targetItem: MessagesTimelineItemState.MessageEvent,
val actions: List<MessagesItemAction>
)

View File

@@ -6,7 +6,6 @@ import androidx.compose.runtime.Stable
data class MessagesItemReactionState( data class MessagesItemReactionState(
val reactions: List<AggregatedReaction> val reactions: List<AggregatedReaction>
) )
@Stable @Stable
data class AggregatedReaction( data class AggregatedReaction(
val key: String, val key: String,

View File

@@ -11,6 +11,7 @@ data class MessagesViewState(
val roomAvatar: AvatarData? = null, val roomAvatar: AvatarData? = null,
val timelineItems: Async<List<MessagesTimelineItemState>> = Uninitialized, val timelineItems: Async<List<MessagesTimelineItemState>> = Uninitialized,
val hasMoreToLoad: Boolean = true, val hasMoreToLoad: Boolean = true,
val itemActionsSheetState: Async<MessagesItemActionsSheetState> = Uninitialized
) : MavericksState { ) : MavericksState {
@Suppress("unused") @Suppress("unused")

View File

@@ -0,0 +1,8 @@
package io.element.android.x.designsystem
import io.element.android.x.libraries.designsystem.R
object VectorIcons {
val Copy = R.drawable.ic_content_copy
val ArrowForward = R.drawable.ic_content_arrow_forward
}

View File

@@ -0,0 +1,22 @@
package io.element.android.x.designsystem.components
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
@Composable
fun VectorIcon(
resourceId: Int,
modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current,
) {
Icon(
painter = painterResource(id = resourceId),
contentDescription = null,
modifier = modifier,
tint = tint
)
}

View File

@@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:tint="#000000" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,4l-1.41,1.41L16.17,11H4v2h12.17l-5.58,5.59L12,20l8,-8z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z"/>
</vector>

View File

@@ -17,6 +17,7 @@ dependencies {
implementation(platform("androidx.compose:compose-bom:2022.10.00")) implementation(platform("androidx.compose:compose-bom:2022.10.00"))
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material:material")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.5.1") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.5.1")