Start implementing an ugly timeline
This commit is contained in:
parent
7907fce9e5
commit
ae051f0885
6 changed files with 174 additions and 38 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
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.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
|
@ -10,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
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.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.Composable
|
||||||
|
|
@ -18,14 +20,16 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.LastBaseline
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.airbnb.mvrx.compose.collectAsState
|
import com.airbnb.mvrx.compose.collectAsState
|
||||||
import com.airbnb.mvrx.compose.mavericksViewModel
|
import com.airbnb.mvrx.compose.mavericksViewModel
|
||||||
import io.element.android.x.core.data.LogCompositions
|
import io.element.android.x.core.data.LogCompositions
|
||||||
import io.element.android.x.designsystem.components.avatar.AvatarData
|
import io.element.android.x.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.x.features.messages.model.MessagesItemGroupPosition
|
||||||
|
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.timeline.MatrixTimelineItem
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesScreen(roomId: String) {
|
fun MessagesScreen(roomId: String) {
|
||||||
|
|
@ -48,7 +52,7 @@ fun MessagesScreen(roomId: String) {
|
||||||
fun MessagesContent(
|
fun MessagesContent(
|
||||||
roomTitle: String?,
|
roomTitle: String?,
|
||||||
roomAvatar: AvatarData?,
|
roomAvatar: AvatarData?,
|
||||||
timelineItems: List<MatrixTimelineItem>,
|
timelineItems: List<MessagesTimelineItemState>,
|
||||||
hasMoreToLoad: Boolean,
|
hasMoreToLoad: Boolean,
|
||||||
onReachedLoadMore: () -> Unit,
|
onReachedLoadMore: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
@ -83,7 +87,7 @@ fun MessagesContent(
|
||||||
fun TimelineItems(
|
fun TimelineItems(
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
lazyListState: LazyListState,
|
lazyListState: LazyListState,
|
||||||
timelineItems: List<MatrixTimelineItem>,
|
timelineItems: List<MessagesTimelineItemState>,
|
||||||
hasMoreToLoad: Boolean,
|
hasMoreToLoad: Boolean,
|
||||||
onReachedLoadMore: () -> Unit,
|
onReachedLoadMore: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
@ -110,36 +114,92 @@ fun TimelineItems(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemRow(
|
fun TimelineItemRow(
|
||||||
timelineItem: MatrixTimelineItem
|
timelineItem: MessagesTimelineItemState
|
||||||
) {
|
) {
|
||||||
when (timelineItem) {
|
when (timelineItem) {
|
||||||
MatrixTimelineItem.Other -> return
|
is MessagesTimelineItemState.Virtual -> return
|
||||||
MatrixTimelineItem.Virtual -> return
|
is MessagesTimelineItemState.MessageEvent -> MessageEventRow(messageEvent = timelineItem)
|
||||||
is MatrixTimelineItem.Event -> {
|
}
|
||||||
Column(
|
}
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
@Composable
|
||||||
.clickable(
|
fun MessageEventRow(
|
||||||
onClick = { },
|
messageEvent: MessagesTimelineItemState.MessageEvent,
|
||||||
indication = rememberRipple(),
|
modifier: Modifier = Modifier
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
) {
|
||||||
),
|
val contentAlignment = if (messageEvent.isMine) {
|
||||||
) {
|
Alignment.CenterEnd
|
||||||
Row(
|
} else {
|
||||||
modifier = Modifier
|
Alignment.CenterStart
|
||||||
.fillMaxWidth()
|
}
|
||||||
.padding(16.dp)
|
Box(
|
||||||
.height(IntrinsicSize.Min),
|
modifier = Modifier
|
||||||
verticalAlignment = Alignment.CenterVertically
|
.fillMaxWidth()
|
||||||
) {
|
.wrapContentHeight(),
|
||||||
Text(
|
contentAlignment = contentAlignment
|
||||||
fontSize = 14.sp,
|
) {
|
||||||
text = timelineItem.event.raw() ?: "",
|
Row(modifier = modifier
|
||||||
)
|
.widthIn(max = 300.dp)
|
||||||
|
.clickable(
|
||||||
|
onClick = { },
|
||||||
|
indication = rememberRipple(),
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
)) {
|
||||||
|
if (!messageEvent.isMine) {
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
if (messageEvent.groupPosition.showSenderInformation() && !messageEvent.isMine) {
|
||||||
|
MessageSenderInformation(messageEvent.sender, messageEvent.senderAvatar)
|
||||||
}
|
}
|
||||||
|
MessageEventBubble(messageEvent)
|
||||||
|
}
|
||||||
|
if (messageEvent.isMine) {
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (messageEvent.groupPosition is MessagesItemGroupPosition.First) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageSenderInformation(sender: String, senderAvatar: AvatarData?) {
|
||||||
|
Row {
|
||||||
|
if (senderAvatar != null) {
|
||||||
|
Avatar(senderAvatar)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = sender,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.alignBy(LastBaseline)
|
||||||
|
.paddingFrom(LastBaseline, after = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageEventBubble(
|
||||||
|
messageEvent: MessagesTimelineItemState.MessageEvent,
|
||||||
|
) {
|
||||||
|
val backgroundBubbleColor = if (messageEvent.isMine) {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = backgroundBubbleColor,
|
||||||
|
shape = RoundedCornerShape(20.dp, 20.dp, 20.dp, 20.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
text = messageEvent.content ?: "",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,23 @@ 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.MessagesItemGroupPosition
|
||||||
|
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
|
||||||
import io.element.android.x.matrix.room.MatrixRoom
|
import io.element.android.x.matrix.room.MatrixRoom
|
||||||
import io.element.android.x.matrix.timeline.MatrixTimeline
|
import io.element.android.x.matrix.timeline.MatrixTimeline
|
||||||
|
import io.element.android.x.matrix.timeline.MatrixTimelineItem
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.matrix.rustcomponents.sdk.MessageType
|
||||||
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
|
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
|
||||||
|
|
||||||
private const val PAGINATION_COUNT = 50
|
private const val PAGINATION_COUNT = 50
|
||||||
|
|
||||||
class MessagesViewModel(
|
class MessagesViewModel(
|
||||||
private val client: MatrixClient,
|
private val client: MatrixClient,
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
|
|
@ -35,15 +41,13 @@ class MessagesViewModel(
|
||||||
val room = client.getRoom(state.roomId) ?: return null
|
val room = client.getRoom(state.roomId) ?: return null
|
||||||
return MessagesViewModel(client, room, room.timeline(), state)
|
return MessagesViewModel(client, room, room.timeline(), state)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
handleInit()
|
handleInit()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMore(){
|
fun loadMore() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
timeline.paginateBackwards(PAGINATION_COUNT)
|
timeline.paginateBackwards(PAGINATION_COUNT)
|
||||||
setState { copy(hasMoreToLoad = timeline.hasMoreToLoad) }
|
setState { copy(hasMoreToLoad = timeline.hasMoreToLoad) }
|
||||||
|
|
@ -51,10 +55,6 @@ class MessagesViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleInit() {
|
private fun handleInit() {
|
||||||
setState {
|
|
||||||
copy(hasMoreToLoad = timeline.hasMoreToLoad)
|
|
||||||
}
|
|
||||||
|
|
||||||
room.syncUpdateFlow()
|
room.syncUpdateFlow()
|
||||||
.onEach {
|
.onEach {
|
||||||
val avatarData =
|
val avatarData =
|
||||||
|
|
@ -67,6 +67,42 @@ class MessagesViewModel(
|
||||||
}.launchIn(viewModelScope)
|
}.launchIn(viewModelScope)
|
||||||
|
|
||||||
timeline.timelineItems()
|
timeline.timelineItems()
|
||||||
|
.map { timelineItems ->
|
||||||
|
val messagesTimelineItemState = ArrayList<MessagesTimelineItemState>()
|
||||||
|
for (index in timelineItems.indices.reversed()) {
|
||||||
|
val currentTimelineItem = timelineItems[index]
|
||||||
|
val timelineItemState = when (currentTimelineItem) {
|
||||||
|
is MatrixTimelineItem.Event -> {
|
||||||
|
val prevTimelineItem = timelineItems.getOrNull(index - 1)
|
||||||
|
val nextTimelineItem = timelineItems.getOrNull(index + 1)
|
||||||
|
|
||||||
|
val messageType =
|
||||||
|
currentTimelineItem.event.content().asMessage()?.msgtype()
|
||||||
|
val contentStr = when (messageType) {
|
||||||
|
is MessageType.Emote -> messageType.content.body
|
||||||
|
is MessageType.Image -> messageType.content.body
|
||||||
|
is MessageType.Notice -> messageType.content.body
|
||||||
|
is MessageType.Text -> messageType.content.body
|
||||||
|
null -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
MessagesTimelineItemState.MessageEvent(
|
||||||
|
id = currentTimelineItem.event.eventId() ?: "",
|
||||||
|
sender = currentTimelineItem.event.sender(),
|
||||||
|
content = contentStr,
|
||||||
|
isMine = currentTimelineItem.event.sender() == client.userId().value,
|
||||||
|
groupPosition = MessagesItemGroupPosition.None
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is MatrixTimelineItem.Virtual -> MessagesTimelineItemState.Virtual(
|
||||||
|
"virtual_item_$index"
|
||||||
|
)
|
||||||
|
MatrixTimelineItem.Other -> continue
|
||||||
|
}
|
||||||
|
messagesTimelineItemState.add(timelineItemState)
|
||||||
|
}
|
||||||
|
messagesTimelineItemState
|
||||||
|
}
|
||||||
.execute {
|
.execute {
|
||||||
copy(timelineItems = it)
|
copy(timelineItems = it)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package io.element.android.x.features.messages.model
|
||||||
|
|
||||||
|
sealed interface MessagesItemGroupPosition {
|
||||||
|
object First : MessagesItemGroupPosition
|
||||||
|
object Middle : MessagesItemGroupPosition
|
||||||
|
object Last : MessagesItemGroupPosition
|
||||||
|
object None : MessagesItemGroupPosition
|
||||||
|
|
||||||
|
fun showSenderInformation(): Boolean {
|
||||||
|
return when (this) {
|
||||||
|
First, None -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package io.element.android.x.features.messages.model
|
||||||
|
|
||||||
|
import io.element.android.x.designsystem.components.avatar.AvatarData
|
||||||
|
|
||||||
|
sealed interface MessagesTimelineItemState {
|
||||||
|
data class Virtual(
|
||||||
|
val id: String
|
||||||
|
) : MessagesTimelineItemState
|
||||||
|
|
||||||
|
data class MessageEvent(
|
||||||
|
val id: String = "",
|
||||||
|
val sender: String = "",
|
||||||
|
val senderAvatar: AvatarData? = null,
|
||||||
|
val content: String? = null,
|
||||||
|
val sentTime: String = "",
|
||||||
|
val isMine: Boolean = false,
|
||||||
|
val groupPosition: MessagesItemGroupPosition = MessagesItemGroupPosition.None
|
||||||
|
) : MessagesTimelineItemState
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -10,8 +10,8 @@ data class MessagesViewState(
|
||||||
val roomId: String,
|
val roomId: String,
|
||||||
val roomName: String? = null,
|
val roomName: String? = null,
|
||||||
val roomAvatar: AvatarData? = null,
|
val roomAvatar: AvatarData? = null,
|
||||||
val timelineItems: Async<List<MatrixTimelineItem>> = Uninitialized,
|
val timelineItems: Async<List<MessagesTimelineItemState>> = Uninitialized,
|
||||||
val hasMoreToLoad: Boolean = false,
|
val hasMoreToLoad: Boolean = true,
|
||||||
) : MavericksState {
|
) : MavericksState {
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class MatrixTimeline(
|
||||||
|
|
||||||
fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
||||||
return diffFlow().combine(timelineItems) { _, _ ->
|
return diffFlow().combine(timelineItems) { _, _ ->
|
||||||
timelineItems.value.reversed()
|
timelineItems.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue