Finish migration of Messages screen

This commit is contained in:
ganfra
2023-01-13 18:05:14 +01:00
parent 2869f492d9
commit 7b197e6e8b
47 changed files with 354 additions and 315 deletions

View File

@@ -19,10 +19,14 @@ package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.x.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.plus
import java.util.concurrent.Executors
@Module
@ContributesTo(AppScope::class)
@@ -33,4 +37,15 @@ object AppModule {
fun providesAppCoroutineScope(): CoroutineScope {
return MainScope() + CoroutineName("ElementX Scope")
}
@Provides
@SingleIn(AppScope::class)
fun providesCoroutineDispatchers(): CoroutineDispatchers {
return CoroutineDispatchers(
io = Dispatchers.IO,
computation = Dispatchers.Default,
main = Dispatchers.Main,
diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
)
}
}

View File

@@ -1,8 +1,8 @@
package io.element.android.x.features.messages
import io.element.android.x.features.messages.actionlist.TimelineItemAction
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.actionlist.model.TimelineItemAction
import io.element.android.x.features.messages.timeline.model.TimelineItem
sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val messageEvent: MessagesTimelineItemState.MessageEvent) : MessagesEvents
data class HandleAction(val action: TimelineItemAction, val messageEvent: TimelineItem.MessageEvent) : MessagesEvents
}

View File

@@ -28,6 +28,7 @@ class MessagesNode @AssistedInject constructor(
MessagesView(
state = state,
onBackPressed = this::navigateUp,
modifier = modifier
)
}
}

View File

@@ -12,13 +12,14 @@ import io.element.android.x.architecture.Presenter
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.designsystem.components.avatar.AvatarSize
import io.element.android.x.features.messages.actionlist.ActionListPresenter
import io.element.android.x.features.messages.actionlist.TimelineItemAction
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
import io.element.android.x.features.messages.actionlist.model.TimelineItemAction
import io.element.android.x.features.messages.textcomposer.MessageComposerEvents
import io.element.android.x.features.messages.textcomposer.MessageComposerPresenter
import io.element.android.x.features.messages.textcomposer.MessageComposerState
import io.element.android.x.features.messages.timeline.TimelineEvents
import io.element.android.x.features.messages.timeline.TimelinePresenter
import io.element.android.x.features.messages.timeline.model.TimelineItem
import io.element.android.x.features.messages.timeline.model.content.TimelineItemTextBasedContent
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.matrix.ui.MatrixItemHelper
@@ -60,7 +61,9 @@ class MessagesPresenter @Inject constructor(
)
roomName.value = room.name
}
LaunchedEffect(composerState.mode.relatedEventId) {
timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
}
fun handleEvents(event: MessagesEvents) {
when (event) {
is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.messageEvent, composerState)
@@ -79,7 +82,7 @@ class MessagesPresenter @Inject constructor(
fun CoroutineScope.handleTimelineAction(
action: TimelineItemAction,
targetEvent: MessagesTimelineItemState.MessageEvent,
targetEvent: TimelineItem.MessageEvent,
composerState: MessageComposerState,
) = launch {
when (action) {
@@ -95,21 +98,21 @@ class MessagesPresenter @Inject constructor(
Timber.v("NotImplementedYet")
}
private suspend fun handleActionRedact(event: MessagesTimelineItemState.MessageEvent) {
private suspend fun handleActionRedact(event: TimelineItem.MessageEvent) {
room.redactEvent(event.id)
}
private fun handleActionEdit(targetEvent: MessagesTimelineItemState.MessageEvent, composerState: MessageComposerState) {
private fun handleActionEdit(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) {
val composerMode = MessageComposerMode.Edit(
targetEvent.id,
(targetEvent.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty()
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty()
)
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)
}
private fun handleActionReply(targetEvent: MessagesTimelineItemState.MessageEvent, composerState: MessageComposerState) {
private fun handleActionReply(targetEvent: TimelineItem.MessageEvent, composerState: MessageComposerState) {
val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, "")
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)

View File

@@ -10,10 +10,10 @@ import io.element.android.x.matrix.core.RoomId
@Immutable
data class MessagesState(
val roomId: RoomId,
val roomName: String? = null,
val roomAvatar: AvatarData? = null,
val roomName: String?,
val roomAvatar: AvatarData?,
val composerState: MessageComposerState,
val timelineState: TimelineState,
val actionListState: ActionListState,
val eventSink: (MessagesEvents) -> Unit = {}
val eventSink: (MessagesEvents) -> Unit
)

View File

@@ -48,8 +48,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -57,11 +59,13 @@ import androidx.compose.ui.unit.sp
import io.element.android.x.core.compose.LogCompositions
import io.element.android.x.designsystem.components.avatar.Avatar
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.features.messages.actionlist.TimelineItemAction
import io.element.android.x.features.messages.actionlist.ActionListEvents
import io.element.android.x.features.messages.actionlist.ActionListView
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.actionlist.model.TimelineItemAction
import io.element.android.x.features.messages.timeline.model.TimelineItem
import io.element.android.x.features.messages.textcomposer.MessageComposerView
import io.element.android.x.features.messages.timeline.TimelineView
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
@@ -76,8 +80,28 @@ fun MessagesView(
initialValue = ModalBottomSheetValue.Hidden,
)
val snackbarHostState = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
val coroutineScope = rememberCoroutineScope()
LogCompositions(tag = "MessagesScreen", msg = "Content")
fun onMessageClicked(messageEvent: TimelineItem.MessageEvent) {
Timber.v("OnMessageClicked= ${messageEvent.id}")
}
fun onMessageLongClicked(messageEvent: TimelineItem.MessageEvent) {
Timber.v("OnMessageLongClicked= ${messageEvent.id}")
focusManager.clearFocus(force = true)
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(messageEvent))
coroutineScope.launch {
itemActionsBottomSheetState.show()
}
}
fun onActionSelected(action: TimelineItemAction, messageEvent: TimelineItem.MessageEvent) {
state.eventSink(MessagesEvents.HandleAction(action, messageEvent))
}
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.statusBars,
@@ -92,6 +116,8 @@ fun MessagesView(
MessagesViewContent(
state = state,
modifier = Modifier.padding(padding),
onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked
)
},
snackbarHost = {
@@ -102,10 +128,6 @@ fun MessagesView(
},
)
fun onActionSelected(action: TimelineItemAction, messageEvent: MessagesTimelineItemState.MessageEvent) {
state.eventSink(MessagesEvents.HandleAction(action, messageEvent))
}
ActionListView(
state = state.actionListState,
modalBottomSheetState = itemActionsBottomSheetState,
@@ -116,43 +138,32 @@ fun MessagesView(
@Composable
fun MessagesViewContent(
state: MessagesState,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onMessageClicked: (TimelineItem.MessageEvent) -> Unit = {},
onMessageLongClicked: (TimelineItem.MessageEvent) -> Unit = {},
) {
fun onMessageClicked(messageEvent: MessagesTimelineItemState.MessageEvent) {
Timber.v("OnMessageClicked= $messageEvent")
}
fun onMessageLongClicked(messageEvent: MessagesTimelineItemState.MessageEvent) {
Timber.v("OnMessageLongClicked= $messageEvent")
}
Column(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding()
) {
// Hide timeline if composer is full screen
if (!state.composerState.isFullScreen) {
TimelineView(
state = state.timelineState,
modifier = Modifier.fillMaxWidth(),
onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked
modifier = Modifier.weight(1f),
onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked
)
}
MessageComposerView(
state = state.composerState,
modifier = Modifier
.fillMaxWidth()
.let {
if (state.composerState.isFullScreen) {
it.weight(1f, fill = false)
} else {
it.wrapContentHeight(Alignment.Bottom)
}
},
.wrapContentHeight(Alignment.Bottom)
)
}
}

View File

@@ -1,8 +1,8 @@
package io.element.android.x.features.messages.actionlist
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.timeline.model.TimelineItem
sealed interface ActionListEvents {
object Clear : ActionListEvents
data class ComputeForMessage(val messageEvent: MessagesTimelineItemState.MessageEvent) : ActionListEvents
data class ComputeForMessage(val messageEvent: TimelineItem.MessageEvent) : ActionListEvents
}

View File

@@ -6,8 +6,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.x.architecture.Presenter
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
import io.element.android.x.features.messages.actionlist.model.TimelineItemAction
import io.element.android.x.features.messages.timeline.model.TimelineItem
import io.element.android.x.features.messages.timeline.model.content.TimelineItemRedactedContent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -37,10 +38,10 @@ class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
)
}
fun CoroutineScope.computeForMessage(messagesTimelineItemState: MessagesTimelineItemState.MessageEvent, target: MutableState<ActionListState.Target>) = launch {
target.value = ActionListState.Target.Loading(messagesTimelineItemState)
fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.MessageEvent, target: MutableState<ActionListState.Target>) = launch {
target.value = ActionListState.Target.Loading(timelineItem)
val actions =
if (messagesTimelineItemState.content is MessagesTimelineItemRedactedContent) {
if (timelineItem.content is TimelineItemRedactedContent) {
emptyList()
} else {
mutableListOf(
@@ -48,12 +49,12 @@ class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
TimelineItemAction.Forward,
TimelineItemAction.Copy,
).also {
if (messagesTimelineItemState.isMine) {
if (timelineItem.isMine) {
it.add(TimelineItemAction.Edit)
it.add(TimelineItemAction.Redact)
}
}
}
target.value = ActionListState.Target.Success(messagesTimelineItemState, actions.toImmutableList())
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
}
}

View File

@@ -17,20 +17,21 @@
package io.element.android.x.features.messages.actionlist
import androidx.compose.runtime.Immutable
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.actionlist.model.TimelineItemAction
import io.element.android.x.features.messages.timeline.model.TimelineItem
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class ActionListState(
val target: Target = Target.None,
val eventSink: (ActionListEvents) -> Unit = {},
val target: Target,
val eventSink: (ActionListEvents) -> Unit,
) {
sealed interface Target {
object None : Target
data class Loading(val messageEvent: MessagesTimelineItemState.MessageEvent) : Target
data class Loading(val messageEvent: TimelineItem.MessageEvent) : Target
data class Success(
val messageEvent: MessagesTimelineItemState.MessageEvent,
val messageEvent: TimelineItem.MessageEvent,
val actions: ImmutableList<TimelineItemAction>,
) : Target
}

View File

@@ -26,7 +26,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import io.element.android.x.designsystem.components.VectorIcon
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.actionlist.model.TimelineItemAction
import io.element.android.x.features.messages.timeline.model.TimelineItem
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@@ -34,7 +35,7 @@ import kotlinx.coroutines.launch
fun ActionListView(
state: ActionListState,
modalBottomSheetState: ModalBottomSheetState,
onActionSelected: (action: TimelineItemAction, MessagesTimelineItemState.MessageEvent) -> Unit,
onActionSelected: (action: TimelineItemAction, TimelineItem.MessageEvent) -> Unit,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
@@ -48,7 +49,7 @@ fun ActionListView(
fun onItemActionClicked(
itemAction: TimelineItemAction,
targetItem: MessagesTimelineItemState.MessageEvent
targetItem: TimelineItem.MessageEvent
) {
onActionSelected(itemAction, targetItem)
coroutineScope.launch {
@@ -75,7 +76,7 @@ fun ActionListView(
private fun SheetContent(
state: ActionListState,
modifier: Modifier = Modifier,
onActionClicked: (TimelineItemAction, MessagesTimelineItemState.MessageEvent) -> Unit = { _, _ -> },
onActionClicked: (TimelineItemAction, TimelineItem.MessageEvent) -> Unit = { _, _ -> },
) {
when (val target = state.target) {
is ActionListState.Target.Loading,

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.actionlist
package io.element.android.x.features.messages.actionlist.model
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable

View File

@@ -1,12 +1,13 @@
package io.element.android.x.features.messages.textcomposer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.x.architecture.Presenter
import io.element.android.x.core.data.StableCharSequence
import io.element.android.x.core.data.toStableCharSequence
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.textcomposer.MessageComposerMode
import kotlinx.coroutines.CoroutineScope
@@ -15,7 +16,6 @@ import javax.inject.Inject
class MessageComposerPresenter @Inject constructor(
private val appCoroutineScope: CoroutineScope,
private val client: MatrixClient,
private val room: MatrixRoom
) : Presenter<MessageComposerState> {
@@ -24,25 +24,32 @@ class MessageComposerPresenter @Inject constructor(
val isFullScreen = rememberSaveable {
mutableStateOf(false)
}
val text: MutableState<CharSequence> = rememberSaveable {
mutableStateOf("")
val text: MutableState<StableCharSequence> = rememberSaveable {
mutableStateOf(StableCharSequence(""))
}
val composerMode: MutableState<MessageComposerMode> = rememberSaveable {
mutableStateOf(MessageComposerMode.Normal(""))
}
LaunchedEffect(composerMode.value) {
when (val modeValue = composerMode.value) {
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent.toStableCharSequence()
else -> Unit
}
}
fun handleEvents(event: MessageComposerEvents) {
when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
is MessageComposerEvents.UpdateText -> text.value = event.text
is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence()
MessageComposerEvents.CloseSpecialMode -> composerMode.setToNormal()
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode)
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text)
is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode
}
}
return MessageComposerState(
text = text.value.toStableCharSequence(),
text = text.value,
isFullScreen = isFullScreen.value,
mode = composerMode.value,
eventSink = ::handleEvents
@@ -53,9 +60,10 @@ class MessageComposerPresenter @Inject constructor(
value = MessageComposerMode.Normal("")
}
private fun CoroutineScope.sendMessage(text: String, composerMode: MutableState<MessageComposerMode>) = launch {
private fun CoroutineScope.sendMessage(text: String, composerMode: MutableState<MessageComposerMode>, textState: MutableState<StableCharSequence>) = launch {
val capturedMode = composerMode.value
// Reset composer right away
textState.value = "".toStableCharSequence()
composerMode.setToNormal()
when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(text)

View File

@@ -22,16 +22,10 @@ import io.element.android.x.textcomposer.MessageComposerMode
@Immutable
data class MessageComposerState(
// val roomId: String,
// val canSendMessage: CanSendStatus = CanSendStatus.Allowed,
val isSendButtonVisible: Boolean = false,
val rootThreadEventId: String? = null,
val startsThread: Boolean = false,
// val sendMode: SendMode = SendMode.Regular("", false),
// val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle,
// val voiceBroadcastState: VoiceBroadcastState? = null,
val text: StableCharSequence? = null,
val isFullScreen: Boolean = false,
val mode: MessageComposerMode = MessageComposerMode.Normal(""),
val eventSink: (MessageComposerEvents) -> Unit = {}
)
val text: StableCharSequence?,
val isFullScreen: Boolean,
val mode: MessageComposerMode,
val eventSink: (MessageComposerEvents) -> Unit
) {
val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not()
}

View File

@@ -7,7 +7,7 @@ import io.element.android.x.textcomposer.TextComposer
@Composable
fun MessageComposerView(
state: MessageComposerState,
modifier: Modifier
modifier: Modifier = Modifier,
) {
fun onFullscreenToggle() {

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,29 +14,31 @@
* limitations under the License.
*/
package io.element.android.x.features.messages
package io.element.android.x.features.messages.timeline
import androidx.recyclerview.widget.DiffUtil
import io.element.android.x.designsystem.components.avatar.AvatarSize
import io.element.android.x.features.messages.diff.CacheInvalidator
import io.element.android.x.features.messages.diff.MatrixTimelineItemsDiffCallback
import io.element.android.x.features.messages.model.AggregatedReaction
import io.element.android.x.features.messages.model.MessagesItemGroupPosition
import io.element.android.x.features.messages.model.MessagesItemReactionState
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.model.content.MessagesTimelineItemContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemEmoteContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemEncryptedContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemImageContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemNoticeContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemUnknownContent
import io.element.android.x.features.messages.util.invalidateLast
import io.element.android.x.features.messages.timeline.diff.CacheInvalidator
import io.element.android.x.features.messages.timeline.diff.MatrixTimelineItemsDiffCallback
import io.element.android.x.features.messages.timeline.model.AggregatedReaction
import io.element.android.x.features.messages.timeline.model.MessagesItemGroupPosition
import io.element.android.x.features.messages.timeline.model.TimelineItemReactions
import io.element.android.x.features.messages.timeline.model.TimelineItem
import io.element.android.x.features.messages.timeline.model.content.TimelineItemContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemEmoteContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemEncryptedContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemImageContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemNoticeContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemRedactedContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemTextContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemUnknownContent
import io.element.android.x.features.messages.timeline.util.invalidateLast
import io.element.android.x.matrix.core.EventId
import io.element.android.x.matrix.media.MediaResolver
import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.matrix.timeline.MatrixTimelineItem
import io.element.android.x.matrix.ui.MatrixItemHelper
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -50,24 +52,25 @@ import org.matrix.rustcomponents.sdk.FormattedBody
import org.matrix.rustcomponents.sdk.MessageFormat
import org.matrix.rustcomponents.sdk.MessageType
import timber.log.Timber
import javax.inject.Inject
import kotlin.system.measureTimeMillis
class MessageTimelineItemStateFactory(
class TimelineItemsFactory @Inject constructor(
private val matrixItemHelper: MatrixItemHelper,
private val room: MatrixRoom,
private val dispatcher: CoroutineDispatcher,
) {
private val timelineItemStates = MutableStateFlow<List<MessagesTimelineItemState>>(emptyList())
private val timelineItemStatesCache = arrayListOf<MessagesTimelineItemState?>()
private val timelineItems = MutableStateFlow<List<TimelineItem>>(emptyList())
private val timelineItemsCache = arrayListOf<TimelineItem?>()
// Items from rust sdk, used for diffing
private var timelineItems: List<MatrixTimelineItem> = emptyList()
private var matrixTimelineItems: List<MatrixTimelineItem> = emptyList()
private val lock = Mutex()
private val cacheInvalidator = CacheInvalidator(timelineItemStatesCache)
private val cacheInvalidator = CacheInvalidator(timelineItemsCache)
fun flow(): StateFlow<List<MessagesTimelineItemState>> = timelineItemStates.asStateFlow()
fun flow(): StateFlow<List<TimelineItem>> = timelineItems.asStateFlow()
suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>,
@@ -83,17 +86,17 @@ class MessageTimelineItemStateFactory(
) = withContext(dispatcher) {
lock.withLock {
// Makes sure to invalidate last as we need to recompute some data (like groupPosition)
timelineItemStatesCache.invalidateLast()
timelineItemStatesCache.add(null)
timelineItems = timelineItems + timelineItem
buildAndEmitTimelineItemStates(timelineItems)
timelineItemsCache.invalidateLast()
timelineItemsCache.add(null)
matrixTimelineItems = matrixTimelineItems + timelineItem
buildAndEmitTimelineItemStates(matrixTimelineItems)
}
}
private suspend fun buildAndEmitTimelineItemStates(timelineItems: List<MatrixTimelineItem>) {
val newTimelineItemStates = ArrayList<MessagesTimelineItemState>()
for (index in timelineItemStatesCache.indices.reversed()) {
val cacheItem = timelineItemStatesCache[index]
val newTimelineItemStates = ArrayList<TimelineItem>()
for (index in timelineItemsCache.indices.reversed()) {
val cacheItem = timelineItemsCache[index]
if (cacheItem == null) {
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
newTimelineItemStates.add(timelineItemState)
@@ -102,18 +105,18 @@ class MessageTimelineItemStateFactory(
newTimelineItemStates.add(cacheItem)
}
}
timelineItemStates.emit(newTimelineItemStates)
this.timelineItems.emit(newTimelineItemStates)
}
private fun calculateAndApplyDiff(newTimelineItems: List<MatrixTimelineItem>) {
val timeToDiff = measureTimeMillis {
val diffCallback =
MatrixTimelineItemsDiffCallback(
oldList = timelineItems,
oldList = matrixTimelineItems,
newList = newTimelineItems
)
val diffResult = DiffUtil.calculateDiff(diffCallback, false)
timelineItems = newTimelineItems
matrixTimelineItems = newTimelineItems
diffResult.dispatchUpdatesTo(cacheInvalidator)
}
Timber.v("Time to apply diff on new list of ${newTimelineItems.size} items: $timeToDiff ms")
@@ -122,7 +125,7 @@ class MessageTimelineItemStateFactory(
private suspend fun buildAndCacheItem(
timelineItems: List<MatrixTimelineItem>,
index: Int
): MessagesTimelineItemState? {
): TimelineItem? {
val timelineItemState =
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> {
@@ -132,12 +135,12 @@ class MessageTimelineItemStateFactory(
timelineItems,
)
}
is MatrixTimelineItem.Virtual -> MessagesTimelineItemState.Virtual(
is MatrixTimelineItem.Virtual -> TimelineItem.Virtual(
"virtual_item_$index"
)
MatrixTimelineItem.Other -> null
}
timelineItemStatesCache[index] = timelineItemState
timelineItemsCache[index] = timelineItemState
return timelineItemState
}
@@ -145,7 +148,7 @@ class MessageTimelineItemStateFactory(
currentTimelineItem: MatrixTimelineItem.Event,
index: Int,
timelineItems: List<MatrixTimelineItem>,
): MessagesTimelineItemState.MessageEvent {
): TimelineItem.MessageEvent {
val currentSender = currentTimelineItem.event.sender()
val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index)
@@ -157,8 +160,8 @@ class MessageTimelineItemStateFactory(
url = senderAvatarUrl,
size = AvatarSize.SMALL
)
return MessagesTimelineItemState.MessageEvent(
id = currentTimelineItem.uniqueId,
return TimelineItem.MessageEvent(
id = EventId(currentTimelineItem.uniqueId),
senderId = currentSender,
senderDisplayName = senderDisplayName,
senderAvatar = senderAvatarData,
@@ -169,24 +172,24 @@ class MessageTimelineItemStateFactory(
)
}
private fun MatrixTimelineItem.Event.computeReactionsState(): MessagesItemReactionState {
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
val aggregatedReactions = event.reactions().map {
AggregatedReaction(key = it.key, count = it.count.toString(), isHighlighted = false)
}
return MessagesItemReactionState(aggregatedReactions)
return TimelineItemReactions(aggregatedReactions.toImmutableList())
}
private fun MatrixTimelineItem.Event.computeContent(): MessagesTimelineItemContent {
private fun MatrixTimelineItem.Event.computeContent(): TimelineItemContent {
val content = event.content()
content.asUnableToDecrypt()?.let { encryptedMessage ->
return MessagesTimelineItemEncryptedContent(encryptedMessage)
return TimelineItemEncryptedContent(encryptedMessage)
}
if (content.isRedactedMessage()) {
return MessagesTimelineItemRedactedContent
return TimelineItemRedactedContent
}
val contentAsMessage = content.asMessage()
return when (val messageType = contentAsMessage?.msgtype()) {
is MessageType.Emote -> MessagesTimelineItemEmoteContent(
is MessageType.Emote -> TimelineItemEmoteContent(
body = messageType.content.body,
htmlDocument = messageType.content.formatted?.toHtmlDocument()
)
@@ -198,7 +201,7 @@ class MessageTimelineItemStateFactory(
} else {
0.7f
}
MessagesTimelineItemImageContent(
TimelineItemImageContent(
body = messageType.content.body,
imageMeta = MediaResolver.Meta(
source = messageType.content.source,
@@ -208,15 +211,15 @@ class MessageTimelineItemStateFactory(
aspectRatio = aspectRatio
)
}
is MessageType.Notice -> MessagesTimelineItemNoticeContent(
is MessageType.Notice -> TimelineItemNoticeContent(
body = messageType.content.body,
htmlDocument = messageType.content.formatted?.toHtmlDocument()
)
is MessageType.Text -> MessagesTimelineItemTextContent(
is MessageType.Text -> TimelineItemTextContent(
body = messageType.content.body,
htmlDocument = messageType.content.formatted?.toHtmlDocument()
)
else -> MessagesTimelineItemUnknownContent
else -> TimelineItemUnknownContent
}
}

View File

@@ -8,15 +8,14 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.x.architecture.Async
import io.element.android.x.architecture.Presenter
import io.element.android.x.features.messages.MessageTimelineItemStateFactory
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.core.EventId
import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.matrix.timeline.MatrixTimeline
import io.element.android.x.matrix.timeline.MatrixTimelineItem
import io.element.android.x.matrix.ui.MatrixItemHelper
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
@@ -34,13 +33,13 @@ class TimelinePresenter @Inject constructor(
private val timeline = room.timeline()
private val matrixItemHelper = MatrixItemHelper(client)
private val messageTimelineItemStateFactory =
MessageTimelineItemStateFactory(matrixItemHelper, room, Dispatchers.Default)
private val timelineItemsFactory =
TimelineItemsFactory(matrixItemHelper, room, Dispatchers.Default)
private class TimelineCallback(private val coroutineScope: CoroutineScope, private val messageTimelineItemStateFactory: MessageTimelineItemStateFactory) : MatrixTimeline.Callback {
private class TimelineCallback(private val coroutineScope: CoroutineScope, private val timelineItemsFactory: TimelineItemsFactory) : MatrixTimeline.Callback {
override fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) {
coroutineScope.launch {
messageTimelineItemStateFactory.pushItem(timelineItem)
timelineItemsFactory.pushItem(timelineItem)
}
}
}
@@ -55,7 +54,7 @@ class TimelinePresenter @Inject constructor(
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
mutableStateOf(null)
}
val timelineItems = messageTimelineItemStateFactory
val timelineItems = timelineItemsFactory
.flow()
.collectAsState(emptyList())
@@ -69,12 +68,12 @@ class TimelinePresenter @Inject constructor(
LaunchedEffect(Unit) {
timeline
.timelineItems()
.onEach(messageTimelineItemStateFactory::replaceWith)
.onEach(timelineItemsFactory::replaceWith)
.launchIn(this)
}
DisposableEffect(Unit) {
timeline.callback = TimelineCallback(localCoroutineScope, messageTimelineItemStateFactory)
timeline.callback = TimelineCallback(localCoroutineScope, timelineItemsFactory)
timeline.initialize()
onDispose {
timeline.callback = null
@@ -84,13 +83,13 @@ class TimelinePresenter @Inject constructor(
return TimelineState(
highlightedEventId = highlightedEventId.value,
timelineItems = Async.Success(timelineItems.value),
timelineItems = timelineItems.value.toImmutableList(),
hasMoreToLoad = hasMoreToLoad.value,
eventSink = ::handleEvents
)
}
fun CoroutineScope.loadMore(hasMoreToLoad: MutableState<Boolean>) = launch {
private fun CoroutineScope.loadMore(hasMoreToLoad: MutableState<Boolean>) = launch {
timeline.paginateBackwards(PAGINATION_COUNT)
hasMoreToLoad.value = timeline.hasMoreToLoad
}

View File

@@ -17,14 +17,14 @@
package io.element.android.x.features.messages.timeline
import androidx.compose.runtime.Immutable
import io.element.android.x.architecture.Async
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.timeline.model.TimelineItem
import io.element.android.x.matrix.core.EventId
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class TimelineState(
val timelineItems: Async<List<MessagesTimelineItemState>> = Async.Uninitialized,
val hasMoreToLoad: Boolean = true,
val highlightedEventId: EventId? = null,
val eventSink: (TimelineEvents) -> Unit = {}
val timelineItems: ImmutableList<TimelineItem>,
val hasMoreToLoad: Boolean,
val highlightedEventId: EventId?,
val eventSink: (TimelineEvents) -> Unit
)

View File

@@ -42,32 +42,31 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import io.element.android.x.architecture.Async
import io.element.android.x.core.compose.PairCombinedPreviewParameter
import io.element.android.x.designsystem.components.avatar.Avatar
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.features.messages.model.AggregatedReaction
import io.element.android.x.features.messages.model.MessagesItemGroupPosition
import io.element.android.x.features.messages.model.MessagesItemGroupPositionProvider
import io.element.android.x.features.messages.model.MessagesItemReactionState
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.model.content.MessagesTimelineItemContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemContentProvider
import io.element.android.x.features.messages.model.content.MessagesTimelineItemEncryptedContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemImageContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemUnknownContent
import io.element.android.x.features.messages.timeline.model.AggregatedReaction
import io.element.android.x.features.messages.timeline.model.MessagesItemGroupPosition
import io.element.android.x.features.messages.timeline.model.TimelineItemGroupPositionProvider
import io.element.android.x.features.messages.timeline.model.TimelineItemReactions
import io.element.android.x.features.messages.timeline.model.TimelineItem
import io.element.android.x.features.messages.timeline.model.content.TimelineItemContent
import io.element.android.x.features.messages.timeline.model.content.MessagesTimelineItemContentProvider
import io.element.android.x.features.messages.timeline.model.content.TimelineItemEncryptedContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemImageContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemRedactedContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemTextBasedContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemUnknownContent
import io.element.android.x.features.messages.timeline.components.MessageEventBubble
import io.element.android.x.features.messages.timeline.components.MessagesReactionsView
import io.element.android.x.features.messages.timeline.components.MessagesTimelineItemEncryptedView
import io.element.android.x.features.messages.timeline.components.MessagesTimelineItemImageView
import io.element.android.x.features.messages.timeline.components.MessagesTimelineItemRedactedView
import io.element.android.x.features.messages.timeline.components.MessagesTimelineItemTextView
import io.element.android.x.features.messages.timeline.components.MessagesTimelineItemUnknownView
import io.element.android.x.features.messages.timeline.components.TimelineItemReactionsView
import io.element.android.x.features.messages.timeline.components.TimelineItemEncryptedView
import io.element.android.x.features.messages.timeline.components.TimelineItemImageView
import io.element.android.x.features.messages.timeline.components.TimelineItemRedactedView
import io.element.android.x.features.messages.timeline.components.TimelineItemTextView
import io.element.android.x.features.messages.timeline.components.TimelineItemUnknownView
import io.element.android.x.matrix.core.EventId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@@ -75,13 +74,11 @@ import kotlinx.coroutines.launch
fun TimelineView(
state: TimelineState,
modifier: Modifier = Modifier,
onMessageClicked: (MessagesTimelineItemState.MessageEvent) -> Unit = {},
onMessageLongClicked: (MessagesTimelineItemState.MessageEvent) -> Unit = {},
onMessageClicked: (TimelineItem.MessageEvent) -> Unit = {},
onMessageLongClicked: (TimelineItem.MessageEvent) -> Unit = {},
) {
val lazyListState = rememberLazyListState()
val timelineItems = state.timelineItems.dataOrNull().orEmpty().toImmutableList()
Box(modifier = modifier.fillMaxWidth()) {
Box(modifier = modifier) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
@@ -90,7 +87,7 @@ fun TimelineView(
reverseLayout = true
) {
items(
items = timelineItems,
items = state.timelineItems,
contentType = { timelineItem -> timelineItem.contentType() },
key = { timelineItem -> timelineItem.key() },
) { timelineItem ->
@@ -114,36 +111,36 @@ fun TimelineView(
TimelineScrollHelper(
lazyListState = lazyListState,
timelineItems = timelineItems,
timelineItems = state.timelineItems,
onLoadMore = ::onReachedLoadMore
)
}
}
private fun MessagesTimelineItemState.key(): String {
private fun TimelineItem.key(): String {
return when (this) {
is MessagesTimelineItemState.MessageEvent -> id
is MessagesTimelineItemState.Virtual -> id
is TimelineItem.MessageEvent -> id.value
is TimelineItem.Virtual -> id
}
}
private fun MessagesTimelineItemState.contentType(): Int {
private fun TimelineItem.contentType(): Int {
return when (this) {
is MessagesTimelineItemState.MessageEvent -> 0
is MessagesTimelineItemState.Virtual -> 1
is TimelineItem.MessageEvent -> 0
is TimelineItem.Virtual -> 1
}
}
@Composable
fun TimelineItemRow(
timelineItem: MessagesTimelineItemState,
timelineItem: TimelineItem,
isHighlighted: Boolean,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
onClick: (TimelineItem.MessageEvent) -> Unit,
onLongClick: (TimelineItem.MessageEvent) -> Unit,
) {
when (timelineItem) {
is MessagesTimelineItemState.Virtual -> return
is MessagesTimelineItemState.MessageEvent -> MessageEventRow(
is TimelineItem.Virtual -> return
is TimelineItem.MessageEvent -> MessageEventRow(
messageEvent = timelineItem,
isHighlighted = isHighlighted,
onClick = { onClick(timelineItem) },
@@ -154,7 +151,7 @@ fun TimelineItemRow(
@Composable
fun MessageEventRow(
messageEvent: MessagesTimelineItemState.MessageEvent,
messageEvent: TimelineItem.MessageEvent,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
@@ -197,32 +194,32 @@ fun MessageEventRow(
) {
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
when (messageEvent.content) {
is MessagesTimelineItemEncryptedContent -> MessagesTimelineItemEncryptedView(
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = messageEvent.content,
modifier = contentModifier
)
is MessagesTimelineItemRedactedContent -> MessagesTimelineItemRedactedView(
is TimelineItemRedactedContent -> TimelineItemRedactedView(
content = messageEvent.content,
modifier = contentModifier
)
is MessagesTimelineItemTextBasedContent -> MessagesTimelineItemTextView(
is TimelineItemTextBasedContent -> TimelineItemTextView(
content = messageEvent.content,
interactionSource = interactionSource,
modifier = contentModifier,
onTextClicked = onClick,
onTextLongClicked = onLongClick
)
is MessagesTimelineItemUnknownContent -> MessagesTimelineItemUnknownView(
is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = messageEvent.content,
modifier = contentModifier
)
is MessagesTimelineItemImageContent -> MessagesTimelineItemImageView(
is TimelineItemImageContent -> TimelineItemImageView(
content = messageEvent.content,
modifier = contentModifier
)
}
}
MessagesReactionsView(
TimelineItemReactionsView(
reactionsState = messageEvent.reactionsState,
modifier = Modifier
.zIndex(1f)
@@ -264,7 +261,7 @@ private fun MessageSenderInformation(
@Composable
internal fun BoxScope.TimelineScrollHelper(
lazyListState: LazyListState,
timelineItems: ImmutableList<MessagesTimelineItemState>,
timelineItems: ImmutableList<TimelineItem>,
onLoadMore: () -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
@@ -338,8 +335,8 @@ internal fun TimelineLoadingMoreIndicator() {
}
class MessagesItemGroupPositionToMessagesTimelineItemContentProvider :
PairCombinedPreviewParameter<MessagesItemGroupPosition, MessagesTimelineItemContent>(
MessagesItemGroupPositionProvider() to MessagesTimelineItemContentProvider()
PairCombinedPreviewParameter<MessagesItemGroupPosition, TimelineItemContent>(
TimelineItemGroupPositionProvider() to MessagesTimelineItemContentProvider()
)
@Suppress("PreviewPublic")
@@ -347,7 +344,7 @@ class MessagesItemGroupPositionToMessagesTimelineItemContentProvider :
@Composable
fun TimelineItemsPreview(
@PreviewParameter(MessagesTimelineItemContentProvider::class)
content: MessagesTimelineItemContent
content: TimelineItemContent
) {
val timelineItems = persistentListOf(
// 3 items (First Middle Last) with isMine = false
@@ -385,23 +382,26 @@ fun TimelineItemsPreview(
)
TimelineView(
state = TimelineState(
timelineItems = Async.Success(timelineItems)
timelineItems = timelineItems,
hasMoreToLoad = true,
highlightedEventId = null,
eventSink = {}
)
)
}
private fun createMessageEvent(
isMine: Boolean,
content: MessagesTimelineItemContent,
content: TimelineItemContent,
groupPosition: MessagesItemGroupPosition
): MessagesTimelineItemState {
return MessagesTimelineItemState.MessageEvent(
id = Math.random().toString(),
): TimelineItem {
return TimelineItem.MessageEvent(
id = EventId(Math.random().toString()),
senderId = "senderId",
senderAvatar = AvatarData("sender"),
content = content,
reactionsState = MessagesItemReactionState(
listOf(
reactionsState = TimelineItemReactions(
persistentListOf(
AggregatedReaction("👍", "1")
)
),

View File

@@ -36,7 +36,7 @@ import io.element.android.x.designsystem.SystemGrey5Dark
import io.element.android.x.designsystem.SystemGrey5Light
import io.element.android.x.designsystem.SystemGrey6Dark
import io.element.android.x.designsystem.SystemGrey6Light
import io.element.android.x.features.messages.model.MessagesItemGroupPosition
import io.element.android.x.features.messages.timeline.model.MessagesItemGroupPosition
private val BUBBLE_RADIUS = 16.dp

View File

@@ -20,14 +20,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.x.features.messages.model.content.MessagesTimelineItemEncryptedContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemEncryptedContent
@Composable
fun MessagesTimelineItemEncryptedView(
content: MessagesTimelineItemEncryptedContent,
fun TimelineItemEncryptedView(
content: TimelineItemEncryptedContent,
modifier: Modifier = Modifier
) {
MessagesTimelineItemInformativeView(
TimelineItemInformativeView(
text = "Decryption error",
iconDescription = "Warning",
icon = Icons.Default.Warning,

View File

@@ -33,11 +33,11 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.element.android.x.features.messages.model.content.MessagesTimelineItemImageContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemImageContent
@Composable
fun MessagesTimelineItemImageView(
content: MessagesTimelineItemImageContent,
fun TimelineItemImageView(
content: TimelineItemImageContent,
modifier: Modifier = Modifier
) {
val widthPercent = if (content.aspectRatio > 1f) {

View File

@@ -32,7 +32,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun MessagesTimelineItemInformativeView(
fun TimelineItemInformativeView(
text: String,
iconDescription: String,
icon: ImageVector,

View File

@@ -32,12 +32,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow
import io.element.android.x.features.messages.model.AggregatedReaction
import io.element.android.x.features.messages.model.MessagesItemReactionState
import io.element.android.x.features.messages.timeline.model.AggregatedReaction
import io.element.android.x.features.messages.timeline.model.TimelineItemReactions
@Composable
fun MessagesReactionsView(
reactionsState: MessagesItemReactionState,
fun TimelineItemReactionsView(
reactionsState: TimelineItemReactions,
modifier: Modifier = Modifier,
) {
if (reactionsState.reactions.isEmpty()) return

View File

@@ -20,15 +20,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
import io.element.android.x.features.messages.timeline.components.MessagesTimelineItemInformativeView
import io.element.android.x.features.messages.timeline.model.content.TimelineItemRedactedContent
@Composable
fun MessagesTimelineItemRedactedView(
content: MessagesTimelineItemRedactedContent,
fun TimelineItemRedactedView(
content: TimelineItemRedactedContent,
modifier: Modifier = Modifier
) {
MessagesTimelineItemInformativeView(
TimelineItemInformativeView(
text = "This message has been deleted",
iconDescription = "Delete",
icon = Icons.Default.Delete,

View File

@@ -31,11 +31,11 @@ import androidx.core.text.util.LinkifyCompat
import io.element.android.x.designsystem.LinkColor
import io.element.android.x.designsystem.components.ClickableLinkText
import io.element.android.x.features.messages.timeline.components.html.HtmlDocument
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemTextBasedContent
@Composable
fun MessagesTimelineItemTextView(
content: MessagesTimelineItemTextBasedContent,
fun TimelineItemTextView(
content: TimelineItemTextBasedContent,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit = {},

View File

@@ -20,14 +20,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.x.features.messages.model.content.MessagesTimelineItemUnknownContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemUnknownContent
@Composable
fun MessagesTimelineItemUnknownView(
content: MessagesTimelineItemUnknownContent,
fun TimelineItemUnknownView(
content: TimelineItemUnknownContent,
modifier: Modifier = Modifier
) {
MessagesTimelineItemInformativeView(
TimelineItemInformativeView(
text = "Event not handled by EAX",
iconDescription = "Info",
icon = Icons.Default.Info,

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,14 +14,14 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.diff
package io.element.android.x.features.messages.timeline.diff
import androidx.recyclerview.widget.ListUpdateCallback
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.util.invalidateLast
import io.element.android.x.features.messages.timeline.model.TimelineItem
import io.element.android.x.features.messages.timeline.util.invalidateLast
import timber.log.Timber
internal class CacheInvalidator(private val itemStatesCache: MutableList<MessagesTimelineItemState?>) :
internal class CacheInvalidator(private val itemStatesCache: MutableList<TimelineItem?>) :
ListUpdateCallback {
override fun onChanged(position: Int, count: Int, payload: Any?) {

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.diff
package io.element.android.x.features.messages.timeline.diff
import androidx.recyclerview.widget.DiffUtil
import io.element.android.x.matrix.timeline.MatrixTimelineItem

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,27 +14,30 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model
package io.element.android.x.features.messages.timeline.model
import androidx.compose.runtime.Immutable
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.features.messages.model.content.MessagesTimelineItemContent
import io.element.android.x.features.messages.timeline.model.content.TimelineItemContent
import io.element.android.x.matrix.core.EventId
sealed interface MessagesTimelineItemState {
@Immutable
sealed interface TimelineItem {
data class Virtual(
val id: String
) : MessagesTimelineItemState
) : TimelineItem
data class MessageEvent(
val id: String,
val id: EventId,
val senderId: String,
val senderDisplayName: String?,
val senderAvatar: AvatarData,
val content: MessagesTimelineItemContent,
val content: TimelineItemContent,
val sentTime: String = "",
val isMine: Boolean = false,
val groupPosition: MessagesItemGroupPosition = MessagesItemGroupPosition.None,
val reactionsState: MessagesItemReactionState
) : MessagesTimelineItemState {
val reactionsState: TimelineItemReactions
) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,10 +14,12 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model
package io.element.android.x.features.messages.timeline.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@Immutable
sealed interface MessagesItemGroupPosition {
object First : MessagesItemGroupPosition
object Middle : MessagesItemGroupPosition
@@ -30,7 +32,7 @@ sealed interface MessagesItemGroupPosition {
}
}
internal class MessagesItemGroupPositionProvider : PreviewParameterProvider<MessagesItemGroupPosition> {
internal class TimelineItemGroupPositionProvider : PreviewParameterProvider<MessagesItemGroupPosition> {
override val values = sequenceOf(
MessagesItemGroupPosition.First,
MessagesItemGroupPosition.Middle,

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,16 +14,14 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model
package io.element.android.x.features.messages.timeline.model
import androidx.compose.runtime.Stable
import kotlinx.collections.immutable.ImmutableList
@Stable
data class MessagesItemReactionState(
val reactions: List<AggregatedReaction>
data class TimelineItemReactions(
val reactions: ImmutableList<AggregatedReaction>
)
@Stable
data class AggregatedReaction(
val key: String,
val count: String,

View File

@@ -14,32 +14,32 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model.content
package io.element.android.x.features.messages.timeline.model.content
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import org.matrix.rustcomponents.sdk.EncryptedMessage
sealed interface MessagesTimelineItemContent
sealed interface TimelineItemContent
class MessagesTimelineItemContentProvider : PreviewParameterProvider<MessagesTimelineItemContent> {
class MessagesTimelineItemContentProvider : PreviewParameterProvider<TimelineItemContent> {
override val values = sequenceOf(
MessagesTimelineItemEmoteContent(
TimelineItemEmoteContent(
body = "Emote",
htmlDocument = null
),
MessagesTimelineItemEncryptedContent(
TimelineItemEncryptedContent(
encryptedMessage = EncryptedMessage.Unknown
),
// TODO MessagesTimelineItemImageContent(),
MessagesTimelineItemNoticeContent(
TimelineItemNoticeContent(
body = "Notice",
htmlDocument = null
),
MessagesTimelineItemRedactedContent,
MessagesTimelineItemTextContent(
TimelineItemRedactedContent,
TimelineItemTextContent(
body = "Text",
htmlDocument = null
),
MessagesTimelineItemUnknownContent,
TimelineItemUnknownContent,
)
}

View File

@@ -14,11 +14,11 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model.content
package io.element.android.x.features.messages.timeline.model.content
import org.jsoup.nodes.Document
data class MessagesTimelineItemTextContent(
data class TimelineItemEmoteContent(
override val body: String,
override val htmlDocument: Document?
) : MessagesTimelineItemTextBasedContent
) : TimelineItemTextBasedContent

View File

@@ -14,10 +14,10 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model.content
package io.element.android.x.features.messages.timeline.model.content
import org.matrix.rustcomponents.sdk.EncryptedMessage
data class MessagesTimelineItemEncryptedContent(
data class TimelineItemEncryptedContent(
val encryptedMessage: EncryptedMessage
) : MessagesTimelineItemContent
) : TimelineItemContent

View File

@@ -14,13 +14,13 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model.content
package io.element.android.x.features.messages.timeline.model.content
import io.element.android.x.matrix.media.MediaResolver
data class MessagesTimelineItemImageContent(
data class TimelineItemImageContent(
val body: String,
val imageMeta: MediaResolver.Meta,
val blurhash: String?,
val aspectRatio: Float
) : MessagesTimelineItemContent
) : TimelineItemContent

View File

@@ -14,11 +14,11 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model.content
package io.element.android.x.features.messages.timeline.model.content
import org.jsoup.nodes.Document
data class MessagesTimelineItemEmoteContent(
data class TimelineItemNoticeContent(
override val body: String,
override val htmlDocument: Document?
) : MessagesTimelineItemTextBasedContent
) : TimelineItemTextBasedContent

View File

@@ -14,6 +14,6 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model.content
package io.element.android.x.features.messages.timeline.model.content
object MessagesTimelineItemUnknownContent : MessagesTimelineItemContent
object TimelineItemRedactedContent : TimelineItemContent

View File

@@ -14,11 +14,11 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model.content
package io.element.android.x.features.messages.timeline.model.content
import org.jsoup.nodes.Document
sealed interface MessagesTimelineItemTextBasedContent : MessagesTimelineItemContent {
sealed interface TimelineItemTextBasedContent : TimelineItemContent {
val body: String
val htmlDocument: Document?
}

View File

@@ -14,11 +14,11 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model.content
package io.element.android.x.features.messages.timeline.model.content
import org.jsoup.nodes.Document
data class MessagesTimelineItemNoticeContent(
data class TimelineItemTextContent(
override val body: String,
override val htmlDocument: Document?
) : MessagesTimelineItemTextBasedContent
) : TimelineItemTextBasedContent

View File

@@ -14,6 +14,6 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model.content
package io.element.android.x.features.messages.timeline.model.content
object MessagesTimelineItemRedactedContent : MessagesTimelineItemContent
object TimelineItemUnknownContent : TimelineItemContent

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.util
package io.element.android.x.features.messages.timeline.util
internal inline fun <reified T> MutableList<T?>.invalidateLast() {
val indexOfLast = size

View File

@@ -25,30 +25,23 @@ import io.element.android.x.matrix.core.SessionId
import io.element.android.x.matrix.session.SessionStore
import io.element.android.x.matrix.session.sessionId
import io.element.android.x.matrix.util.logError
import java.io.File
import java.util.concurrent.Executors
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.AuthenticationService
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@SingleIn(AppScope::class)
class Matrix @Inject constructor(
private val coroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
@ApplicationContext context: Context,
) {
private val coroutineDispatchers = CoroutineDispatchers(
io = Dispatchers.IO,
computation = Dispatchers.Default,
main = Dispatchers.Main,
diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
)
private val baseDirectory = File(context.filesDir, "sessions")
private val sessionStore = SessionStore(context)
private val authService = AuthenticationService(baseDirectory.absolutePath)
@@ -57,7 +50,7 @@ class Matrix @Inject constructor(
return sessionStore.isLoggedIn()
}
suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io){
suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io) {
sessionStore.getLatestSession()?.sessionId()
}

View File

@@ -17,6 +17,7 @@
package io.element.android.x.matrix.room
import io.element.android.x.core.coroutine.CoroutineDispatchers
import io.element.android.x.matrix.core.EventId
import io.element.android.x.matrix.core.RoomId
import io.element.android.x.matrix.timeline.MatrixTimeline
import kotlinx.coroutines.CoroutineScope
@@ -39,13 +40,15 @@ class MatrixRoom(
private val coroutineDispatchers: CoroutineDispatchers,
) {
fun syncUpdateFlow(): Flow<Unit> {
fun syncUpdateFlow(): Flow<Long> {
return slidingSyncUpdateFlow
.filter {
it.rooms.contains(room.id())
}
.map { }
.onStart { emit(Unit) }
.map {
System.currentTimeMillis()
}
.onStart { emit(System.currentTimeMillis()) }
}
fun timeline(): MatrixTimeline {
@@ -107,26 +110,26 @@ class MatrixRoom(
}
}
suspend fun editMessage(originalEventId: String, message: String): Result<Unit> = withContext(coroutineDispatchers.io) {
suspend fun editMessage(originalEventId: EventId, message: String): Result<Unit> = withContext(coroutineDispatchers.io) {
val transactionId = genTransactionId()
// val content = messageEventContentFromMarkdown(message)
runCatching {
room.edit(/* TODO use content */ message, originalEventId, transactionId)
room.edit(/* TODO use content */ message, originalEventId.value, transactionId)
}
}
suspend fun replyMessage(eventId: String, message: String): Result<Unit> = withContext(coroutineDispatchers.io) {
suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> = withContext(coroutineDispatchers.io) {
val transactionId = genTransactionId()
// val content = messageEventContentFromMarkdown(message)
runCatching {
room.sendReply(/* TODO use content */ message, eventId, transactionId)
room.sendReply(/* TODO use content */ message, eventId.value, transactionId)
}
}
suspend fun redactEvent(eventId: String, reason: String? = null) = withContext(coroutineDispatchers.io) {
suspend fun redactEvent(eventId: EventId, reason: String? = null) = withContext(coroutineDispatchers.io) {
val transactionId = genTransactionId()
runCatching {
room.redact(eventId, reason, transactionId)
room.redact(eventId.value, reason, transactionId)
}
}
}

View File

@@ -17,6 +17,7 @@
package io.element.android.x.matrix.timeline
import io.element.android.x.core.coroutine.CoroutineDispatchers
import io.element.android.x.matrix.core.EventId
import io.element.android.x.matrix.room.MatrixRoom
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
@@ -146,11 +147,11 @@ class MatrixTimeline(
return matrixRoom.sendMessage(message)
}
suspend fun editMessage(originalEventId: String, message: String): Result<Unit> {
suspend fun editMessage(originalEventId: EventId, message: String): Result<Unit> {
return matrixRoom.editMessage(originalEventId, message = message)
}
suspend fun replyMessage(inReplyToEventId: String, message: String): Result<Unit> {
suspend fun replyMessage(inReplyToEventId: EventId, message: String): Result<Unit> {
return matrixRoom.replyMessage(inReplyToEventId, message)
}

View File

@@ -26,8 +26,9 @@ import io.element.android.x.matrix.ui.model.MatrixUser
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import javax.inject.Inject
class MatrixItemHelper(
class MatrixItemHelper @Inject constructor(
private val client: MatrixClient
) {
/**

View File

@@ -32,6 +32,7 @@ android {
dependencies {
implementation(project(":libraries:elementresources"))
implementation(project(":libraries:core"))
implementation(project(":libraries:matrix"))
implementation(libs.wysiwyg)
implementation(libs.androidx.constraintlayout)
implementation("com.google.android.material:material:1.7.0")

View File

@@ -17,31 +17,32 @@
package io.element.android.x.textcomposer
import android.os.Parcelable
import io.element.android.x.matrix.core.EventId
import kotlinx.parcelize.Parcelize
sealed interface MessageComposerMode : Parcelable {
@Parcelize
data class Normal(val content: CharSequence?) : MessageComposerMode
sealed class Special(open val eventId: String, open val defaultContent: CharSequence) :
sealed class Special(open val eventId: EventId, open val defaultContent: CharSequence) :
MessageComposerMode
@Parcelize
data class Edit(override val eventId: String, override val defaultContent: CharSequence) :
data class Edit(override val eventId: EventId, override val defaultContent: CharSequence) :
Special(eventId, defaultContent)
@Parcelize
class Quote(override val eventId: String, override val defaultContent: CharSequence) :
class Quote(override val eventId: EventId, override val defaultContent: CharSequence) :
Special(eventId, defaultContent)
@Parcelize
class Reply(
val senderName: String,
override val eventId: String,
override val eventId: EventId,
override val defaultContent: CharSequence
) : Special(eventId, defaultContent)
val relatedEventId: String?
val relatedEventId: EventId?
get() = when (this) {
is Normal -> null
is Edit -> eventId