Browse Source

Start migrating messages screen

feature/bma/flipper
ganfra 2 years ago
parent
commit
020fd3b458
  1. 14
      app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt
  2. 52
      app/src/main/java/io/element/android/x/node/RoomFlowNode.kt
  3. 8
      features/messages/src/main/java/io/element/android/x/features/messages/MessagesEvents.kt
  4. 40
      features/messages/src/main/java/io/element/android/x/features/messages/MessagesNode.kt
  5. 91
      features/messages/src/main/java/io/element/android/x/features/messages/MessagesPresenter.kt
  6. 689
      features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt
  7. 19
      features/messages/src/main/java/io/element/android/x/features/messages/MessagesState.kt
  8. 193
      features/messages/src/main/java/io/element/android/x/features/messages/MessagesView.kt
  9. 228
      features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt
  10. 8
      features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListEvents.kt
  11. 59
      features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListPresenter.kt
  12. 37
      features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListState.kt
  13. 116
      features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListView.kt
  14. 18
      features/messages/src/main/java/io/element/android/x/features/messages/actionlist/TimelineItemAction.kt
  15. 145
      features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemActionsSheet.kt
  16. 45
      features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt
  17. 11
      features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerEvents.kt
  18. 73
      features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerPresenter.kt
  19. 12
      features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerState.kt
  20. 40
      features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerView.kt
  21. 54
      features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewModel.kt
  22. 8
      features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineEvents.kt
  23. 97
      features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelinePresenter.kt
  24. 30
      features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineState.kt
  25. 412
      features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineView.kt
  26. 2
      features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessageEventBubble.kt
  27. 2
      features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesReactionsView.kt
  28. 2
      features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemEncryptedView.kt
  29. 2
      features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemImageView.kt
  30. 2
      features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemInformativeView.kt
  31. 3
      features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemRedactedView.kt
  32. 4
      features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemTextView.kt
  33. 2
      features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemUnknownView.kt
  34. 2
      features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/html/HtmlDocument.kt
  35. 10
      libraries/di/src/main/java/io/element/android/x/di/RoomScope.kt
  36. 2
      libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt
  37. 1
      libraries/textcomposer/build.gradle.kts
  38. 9
      libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt

14
app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt

@ -8,11 +8,8 @@ import com.bumble.appyx.core.modality.BuildContext @@ -8,11 +8,8 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.x.architecture.createNode
import io.element.android.x.architecture.viewmodel.viewModelSupportNode
import io.element.android.x.features.messages.MessagesScreen
import io.element.android.x.features.preferences.PreferencesFlowNode
import io.element.android.x.features.roomlist.RoomListNode
import io.element.android.x.matrix.core.RoomId
@ -34,7 +31,7 @@ class LoggedInFlowNode( @@ -34,7 +31,7 @@ class LoggedInFlowNode(
private val roomListCallback = object : RoomListNode.Callback {
override fun onRoomClicked(roomId: RoomId) {
backstack.push(NavTarget.Messages(roomId))
backstack.push(NavTarget.Room(roomId))
}
override fun onSettingsClicked() {
@ -47,7 +44,7 @@ class LoggedInFlowNode( @@ -47,7 +44,7 @@ class LoggedInFlowNode(
object RoomList : NavTarget
@Parcelize
data class Messages(val roomId: RoomId) : NavTarget
data class Room(val roomId: RoomId) : NavTarget
@Parcelize
object Settings : NavTarget
@ -58,11 +55,8 @@ class LoggedInFlowNode( @@ -58,11 +55,8 @@ class LoggedInFlowNode(
NavTarget.RoomList -> {
createNode<RoomListNode>(buildContext, plugins = listOf(roomListCallback))
}
is NavTarget.Messages -> viewModelSupportNode(buildContext) {
MessagesScreen(
roomId = navTarget.roomId.value,
onBackPressed = { backstack.pop() }
)
is NavTarget.Room -> {
RoomFlowNode(buildContext, navTarget.roomId)
}
NavTarget.Settings -> {
PreferencesFlowNode(buildContext, onOpenBugReport)

52
app/src/main/java/io/element/android/x/node/RoomFlowNode.kt

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
package io.element.android.x.node
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.navmodel.backstack.BackStack
import io.element.android.x.architecture.createNode
import io.element.android.x.features.messages.MessagesNode
import io.element.android.x.matrix.core.RoomId
import kotlinx.parcelize.Parcelize
import timber.log.Timber
class RoomFlowNode(
buildContext: BuildContext,
private val roomId: RoomId,
private val backstack: BackStack<NavTarget> = BackStack(
initialElement = NavTarget.Messages,
savedStateMap = buildContext.savedStateMap,
),
) : ParentNode<RoomFlowNode.NavTarget>(
navModel = backstack,
buildContext = buildContext
) {
init {
lifecycle.subscribe(
onCreate = { Timber.v("OnCreate") },
onDestroy = { Timber.v("OnDestroy") }
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Messages -> createNode<MessagesNode>(buildContext)
}
}
sealed interface NavTarget : Parcelable {
@Parcelize
object Messages : NavTarget
}
@Composable
override fun View(modifier: Modifier) {
Children(navModel = backstack)
}
}

8
features/messages/src/main/java/io/element/android/x/features/messages/MessagesEvents.kt

@ -0,0 +1,8 @@ @@ -0,0 +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
sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val messageEvent: MessagesTimelineItemState.MessageEvent) : MessagesEvents
}

40
features/messages/src/main/java/io/element/android/x/features/messages/MessagesNode.kt

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
package io.element.android.x.features.messages
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesNode
import io.element.android.x.di.SessionScope
@ContributesNode(SessionScope::class)
class MessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
//presenter: MessagesPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
/*
val state by connector.stateFlow.collectAsState()
MessagesView(
state = state,
onBackPressed = this::navigateUp,
)
*/
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = "MESSAGES NODE")
}
}
}

91
features/messages/src/main/java/io/element/android/x/features/messages/MessagesPresenter.kt

@ -0,0 +1,91 @@ @@ -0,0 +1,91 @@
package io.element.android.x.features.messages
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.x.architecture.Presenter
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.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.TimelinePresenter
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.core.RoomId
import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.textcomposer.MessageComposerMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class MessagesPresenter @Inject constructor(
private val client: MatrixClient,
private val roomId: RoomId,
private val room: MatrixRoom,
private val composerPresenter: MessageComposerPresenter,
private val timelinePresenter: TimelinePresenter,
private val actionListPresenter: ActionListPresenter,
) : Presenter<MessagesState> {
@Composable
override fun present(): MessagesState {
val localCoroutineScope = rememberCoroutineScope()
val composerState = composerPresenter.present()
val timelineState = timelinePresenter.present()
val actionListState = actionListPresenter.present()
fun handleEvents(event: MessagesEvents) {
when (event) {
is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.messageEvent, composerState)
}
}
return MessagesState(
roomId = roomId,
composerState = composerState,
timelineState = timelineState,
actionListState = actionListState,
eventSink = ::handleEvents
)
}
fun CoroutineScope.handleTimelineAction(
action: TimelineItemAction,
targetEvent: MessagesTimelineItemState.MessageEvent,
composerState: MessageComposerState,
) = launch {
when (action) {
TimelineItemAction.Copy -> notImplementedYet()
TimelineItemAction.Forward -> notImplementedYet()
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
}
}
private fun notImplementedYet() {
Timber.v("NotImplementedYet")
}
private suspend fun handleActionRedact(event: MessagesTimelineItemState.MessageEvent) {
room.redactEvent(event.id)
}
private fun handleActionEdit(targetEvent: MessagesTimelineItemState.MessageEvent, composerState: MessageComposerState) {
val composerMode = MessageComposerMode.Edit(
targetEvent.id,
(targetEvent.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty()
)
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)
}
private fun handleActionReply(targetEvent: MessagesTimelineItemState.MessageEvent, composerState: MessageComposerState) {
val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, "")
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)
}
}

689
features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt

@ -1,689 +0,0 @@ @@ -1,689 +0,0 @@
/*
* Copyright (c) 2022 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(
ExperimentalMaterial3Api::class,
ExperimentalMaterialApi::class,
ExperimentalComposeUiApi::class
)
package io.element.android.x.features.messages
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.End
import androidx.compose.ui.Alignment.Companion.Start
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.core.compose.LogCompositions
import io.element.android.x.core.compose.PairCombinedPreviewParameter
import io.element.android.x.core.data.StableCharSequence
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.components.MessageEventBubble
import io.element.android.x.features.messages.components.MessagesReactionsView
import io.element.android.x.features.messages.components.MessagesTimelineItemEncryptedView
import io.element.android.x.features.messages.components.MessagesTimelineItemImageView
import io.element.android.x.features.messages.components.MessagesTimelineItemRedactedView
import io.element.android.x.features.messages.components.MessagesTimelineItemTextView
import io.element.android.x.features.messages.components.MessagesTimelineItemUnknownView
import io.element.android.x.features.messages.components.TimelineItemActionsScreen
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.MessagesViewState
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.textcomposer.MessageComposerViewModel
import io.element.android.x.features.messages.textcomposer.MessageComposerViewState
import io.element.android.x.textcomposer.MessageComposerMode
import io.element.android.x.textcomposer.TextComposer
import java.lang.Math.random
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
fun MessagesScreen(
roomId: String,
onBackPressed: () -> Unit,
viewModel: MessagesViewModel = mavericksViewModel(argsFactory = { roomId }),
composerViewModel: MessageComposerViewModel = mavericksViewModel(argsFactory = { roomId })
) {
fun onSendMessage(textMessage: String) {
viewModel.sendMessage(textMessage)
composerViewModel.updateText("")
}
LogCompositions(tag = "MessagesScreen", msg = "Root")
val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val roomTitle by viewModel.collectAsState(MessagesViewState::roomName)
val roomAvatar by viewModel.collectAsState(MessagesViewState::roomAvatar)
val timelineItems by viewModel.collectAsState(MessagesViewState::timelineItems)
val hasMoreToLoad by viewModel.collectAsState(MessagesViewState::hasMoreToLoad)
val snackBarContent by viewModel.collectAsState(MessagesViewState::snackbarContent)
val composerMode by viewModel.collectAsState(MessagesViewState::composerMode)
val highlightedEventId by viewModel.collectAsState(MessagesViewState::highlightedEventId)
val composerFullScreen by composerViewModel.collectAsState(MessageComposerViewState::isFullScreen)
val composerCanSendMessage by composerViewModel.collectAsState(MessageComposerViewState::isSendButtonVisible)
val composerText by composerViewModel.collectAsState(MessageComposerViewState::text)
MessagesScreenContent(
roomTitle = roomTitle,
roomAvatar = roomAvatar,
timelineItems = timelineItems().orEmpty().toImmutableList(),
hasMoreToLoad = hasMoreToLoad,
onReachedLoadMore = viewModel::loadMore,
onBackPressed = onBackPressed,
onSendMessage = ::onSendMessage,
composerFullScreen = composerFullScreen,
onComposerFullScreenChange = composerViewModel::onComposerFullScreenChange,
onComposerTextChange = composerViewModel::updateText,
composerMode = composerMode,
highlightedEventId = highlightedEventId,
onCloseSpecialMode = viewModel::setNormalMode,
composerCanSendMessage = composerCanSendMessage,
composerText = composerText,
onClick = {
Timber.v("onClick on timeline item: ${it.id}")
},
onLongClick = {
focusManager.clearFocus(force = true)
viewModel.computeActionsSheetState(it)
coroutineScope.launch {
itemActionsBottomSheetState.show()
}
},
snackbarHostState = snackbarHostState,
)
TimelineItemActionsScreen(
viewModel = viewModel,
composerViewModel = composerViewModel,
modalBottomSheetState = itemActionsBottomSheetState,
)
snackBarContent?.let {
coroutineScope.launch {
snackbarHostState.showSnackbar(it)
}
viewModel.onSnackbarShown()
}
}
@Composable
fun MessagesScreenContent(
roomTitle: String?,
roomAvatar: AvatarData?,
timelineItems: ImmutableList<MessagesTimelineItemState>,
hasMoreToLoad: Boolean,
onReachedLoadMore: () -> Unit,
onBackPressed: () -> Unit,
onSendMessage: (String) -> Unit,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
composerFullScreen: Boolean,
onComposerFullScreenChange: () -> Unit,
onComposerTextChange: (CharSequence) -> Unit,
composerMode: MessageComposerMode,
highlightedEventId: String?,
onCloseSpecialMode: () -> Unit,
composerCanSendMessage: Boolean,
composerText: StableCharSequence?,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier,
) {
LogCompositions(tag = "MessagesScreen", msg = "Content")
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.statusBars,
topBar = {
MessagesTopAppBar(
roomTitle = roomTitle,
roomAvatar = roomAvatar,
onBackPressed = onBackPressed
)
},
content = { padding ->
MessagesContent(
modifier = Modifier.padding(padding),
timelineItems = timelineItems,
hasMoreToLoad = hasMoreToLoad,
onReachedLoadMore = onReachedLoadMore,
onSendMessage = onSendMessage,
onClick = onClick,
onLongClick = onLongClick,
highlightedEventId = highlightedEventId,
composerMode = composerMode,
onCloseSpecialMode = onCloseSpecialMode,
composerFullScreen = composerFullScreen,
onComposerFullScreenChange = onComposerFullScreenChange,
onComposerTextChange = onComposerTextChange,
composerCanSendMessage = composerCanSendMessage,
composerText = composerText
)
},
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.navigationBarsPadding()
)
},
)
}
@Composable
fun MessagesContent(
timelineItems: ImmutableList<MessagesTimelineItemState>,
hasMoreToLoad: Boolean,
onReachedLoadMore: () -> Unit,
onSendMessage: (String) -> Unit,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
composerMode: MessageComposerMode,
highlightedEventId: String?,
onCloseSpecialMode: () -> Unit,
composerFullScreen: Boolean,
onComposerFullScreenChange: () -> Unit,
onComposerTextChange: (CharSequence) -> Unit,
composerCanSendMessage: Boolean,
composerText: StableCharSequence?,
modifier: Modifier = Modifier
) {
val lazyListState = rememberLazyListState()
Column(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding()
) {
if (!composerFullScreen) {
TimelineItems(
lazyListState = lazyListState,
timelineItems = timelineItems,
highlightedEventId = highlightedEventId,
hasMoreToLoad = hasMoreToLoad,
onReachedLoadMore = onReachedLoadMore,
modifier = Modifier.weight(1f),
onClick = onClick,
onLongClick = onLongClick
)
}
TextComposer(
onSendMessage = onSendMessage,
fullscreen = composerFullScreen,
onFullscreenToggle = onComposerFullScreenChange,
composerMode = composerMode,
onCloseSpecialMode = onCloseSpecialMode,
onComposerTextChange = onComposerTextChange,
composerCanSendMessage = composerCanSendMessage,
composerText = composerText?.charSequence?.toString(),
modifier = Modifier
.fillMaxWidth()
.let {
if (composerFullScreen) {
it.weight(1f, fill = false)
} else {
it.wrapContentHeight(Alignment.Bottom)
}
},
)
}
}
@Composable
fun MessagesTopAppBar(
roomTitle: String?,
roomAvatar: AvatarData?,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
TopAppBar(
modifier = modifier,
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (roomAvatar != null) {
Avatar(roomAvatar)
Spacer(modifier = Modifier.width(8.dp))
}
Text(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
text = roomTitle ?: "Unknown room",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
)
}
@Composable
fun TimelineItems(
lazyListState: LazyListState,
timelineItems: ImmutableList<MessagesTimelineItemState>,
highlightedEventId: String?,
modifier: Modifier = Modifier,
hasMoreToLoad: Boolean = false,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit = {},
onLongClick: ((MessagesTimelineItemState.MessageEvent)) -> Unit = {},
onReachedLoadMore: () -> Unit = {},
) {
Box(modifier = modifier.fillMaxWidth()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Bottom,
reverseLayout = true
) {
items(
items = timelineItems,
contentType = { timelineItem -> timelineItem.contentType() },
key = { timelineItem -> timelineItem.key() },
) { timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
isHighlighted = timelineItem.key() == highlightedEventId,
onClick = onClick,
onLongClick = onLongClick
)
}
if (hasMoreToLoad) {
item {
MessagesLoadingMoreIndicator()
}
}
}
MessagesScrollHelper(
lazyListState = lazyListState,
timelineItems = timelineItems,
onLoadMore = onReachedLoadMore
)
}
}
private fun MessagesTimelineItemState.key(): String {
return when (this) {
is MessagesTimelineItemState.MessageEvent -> id
is MessagesTimelineItemState.Virtual -> id
}
}
private fun MessagesTimelineItemState.contentType(): Int {
return when (this) {
is MessagesTimelineItemState.MessageEvent -> 0
is MessagesTimelineItemState.Virtual -> 1
}
}
@Composable
fun TimelineItemRow(
timelineItem: MessagesTimelineItemState,
isHighlighted: Boolean,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
) {
when (timelineItem) {
is MessagesTimelineItemState.Virtual -> return
is MessagesTimelineItemState.MessageEvent -> MessageEventRow(
messageEvent = timelineItem,
isHighlighted = isHighlighted,
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) }
)
}
}
@Composable
fun MessageEventRow(
messageEvent: MessagesTimelineItemState.MessageEvent,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
val (parentAlignment, contentAlignment) = if (messageEvent.isMine) {
Pair(Alignment.CenterEnd, End)
} else {
Pair(Alignment.CenterStart, Start)
}
Box(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
contentAlignment = parentAlignment
) {
Row {
if (!messageEvent.isMine) {
Spacer(modifier = Modifier.width(16.dp))
}
Column(horizontalAlignment = contentAlignment) {
if (messageEvent.showSenderInformation) {
MessageSenderInformation(
messageEvent.safeSenderName,
messageEvent.senderAvatar,
Modifier.zIndex(1f)
)
}
MessageEventBubble(
groupPosition = messageEvent.groupPosition,
isMine = messageEvent.isMine,
interactionSource = interactionSource,
isHighlighted = isHighlighted,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.zIndex(-1f)
.widthIn(max = 320.dp)
) {
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
when (messageEvent.content) {
is MessagesTimelineItemEncryptedContent -> MessagesTimelineItemEncryptedView(
content = messageEvent.content,
modifier = contentModifier
)
is MessagesTimelineItemRedactedContent -> MessagesTimelineItemRedactedView(
content = messageEvent.content,
modifier = contentModifier
)
is MessagesTimelineItemTextBasedContent -> MessagesTimelineItemTextView(
content = messageEvent.content,
interactionSource = interactionSource,
modifier = contentModifier,
onTextClicked = onClick,
onTextLongClicked = onLongClick
)
is MessagesTimelineItemUnknownContent -> MessagesTimelineItemUnknownView(
content = messageEvent.content,
modifier = contentModifier
)
is MessagesTimelineItemImageContent -> MessagesTimelineItemImageView(
content = messageEvent.content,
modifier = contentModifier
)
}
}
MessagesReactionsView(
reactionsState = messageEvent.reactionsState,
modifier = Modifier
.zIndex(1f)
.offset(x = if (messageEvent.isMine) 0.dp else 20.dp, y = -(16.dp))
)
}
if (messageEvent.isMine) {
Spacer(modifier = Modifier.width(16.dp))
}
}
}
if (messageEvent.groupPosition.isNew()) {
Spacer(modifier = modifier.height(8.dp))
} else {
Spacer(modifier = modifier.height(2.dp))
}
}
@Composable
private fun MessageSenderInformation(
sender: String,
senderAvatar: AvatarData?,
modifier: Modifier = Modifier
) {
Row(modifier = modifier) {
if (senderAvatar != null) {
Avatar(senderAvatar)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = sender,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.alignBy(LastBaseline)
)
}
}
@Composable
internal fun BoxScope.MessagesScrollHelper(
lazyListState: LazyListState,
timelineItems: ImmutableList<MessagesTimelineItemState>,
onLoadMore: () -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
// Auto-scroll when new timeline items appear
LaunchedEffect(timelineItems, firstVisibleItemIndex) {
if (!lazyListState.isScrollInProgress &&
firstVisibleItemIndex < 2
) coroutineScope.launch {
lazyListState.animateScrollToItem(0)
}
}
// Handle load more preloading
val loadMore by remember {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
lastVisibleItemIndex > (totalItemsNumber - 30)
}
}
LaunchedEffect(loadMore) {
snapshotFlow { loadMore }
.distinctUntilChanged()
.collect {
onLoadMore()
}
}
// Jump to bottom button
if (firstVisibleItemIndex > 2) {
FloatingActionButton(
onClick = {
coroutineScope.launch {
if (firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0)
}
}
},
shape = CircleShape,
modifier = Modifier
.align(Alignment.BottomCenter)
.size(40.dp),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
) {
Icon(Icons.Default.ArrowDownward, "")
}
}
}
@Composable
internal fun MessagesLoadingMoreIndicator() {
Box(
Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
class MessagesItemGroupPositionToMessagesTimelineItemContentProvider :
PairCombinedPreviewParameter<MessagesItemGroupPosition, MessagesTimelineItemContent>(
MessagesItemGroupPositionProvider() to MessagesTimelineItemContentProvider()
)
@Suppress("PreviewPublic")
@Preview(showBackground = true)
@Composable
fun TimelineItemsPreview(
@PreviewParameter(MessagesTimelineItemContentProvider::class)
content: MessagesTimelineItemContent
) {
TimelineItems(
lazyListState = LazyListState(),
timelineItems = persistentListOf(
// 3 items (First Middle Last) with isMine = false
createMessageEvent(
isMine = false,
content = content,
groupPosition = MessagesItemGroupPosition.First
),
createMessageEvent(
isMine = false,
content = content,
groupPosition = MessagesItemGroupPosition.Middle
),
createMessageEvent(
isMine = false,
content = content,
groupPosition = MessagesItemGroupPosition.Last
),
// 3 items (First Middle Last) with isMine = true
createMessageEvent(
isMine = true,
content = content,
groupPosition = MessagesItemGroupPosition.First
),
createMessageEvent(
isMine = true,
content = content,
groupPosition = MessagesItemGroupPosition.Middle
),
createMessageEvent(
isMine = true,
content = content,
groupPosition = MessagesItemGroupPosition.Last
),
),
highlightedEventId = null,
hasMoreToLoad = true,
)
}
private fun createMessageEvent(
isMine: Boolean,
content: MessagesTimelineItemContent,
groupPosition: MessagesItemGroupPosition
): MessagesTimelineItemState {
return MessagesTimelineItemState.MessageEvent(
id = random().toString(),
senderId = "senderId",
senderAvatar = AvatarData("sender"),
content = content,
reactionsState = MessagesItemReactionState(
listOf(
AggregatedReaction("👍", "1")
)
),
isMine = isMine,
senderDisplayName = "sender",
groupPosition = groupPosition,
)
}

19
features/messages/src/main/java/io/element/android/x/features/messages/MessagesState.kt

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
package io.element.android.x.features.messages
import androidx.compose.runtime.Immutable
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.features.messages.actionlist.ActionListState
import io.element.android.x.features.messages.textcomposer.MessageComposerState
import io.element.android.x.features.messages.timeline.TimelineState
import io.element.android.x.matrix.core.RoomId
@Immutable
data class MessagesState(
val roomId: RoomId,
val roomName: String? = null,
val roomAvatar: AvatarData? = null,
val composerState: MessageComposerState,
val timelineState: TimelineState,
val actionListState: ActionListState,
val eventSink: (MessagesEvents) -> Unit = {}
)

193
features/messages/src/main/java/io/element/android/x/features/messages/MessagesView.kt

@ -0,0 +1,193 @@ @@ -0,0 +1,193 @@
/*
* Copyright (c) 2022 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(
ExperimentalMaterial3Api::class,
ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class,
)
package io.element.android.x.features.messages
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.ActionListView
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.textcomposer.MessageComposerView
import io.element.android.x.features.messages.timeline.TimelineView
import timber.log.Timber
@Composable
fun MessagesView(
state: MessagesState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit,
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
val snackbarHostState = remember { SnackbarHostState() }
LogCompositions(tag = "MessagesScreen", msg = "Content")
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.statusBars,
topBar = {
MessagesViewTopBar(
roomTitle = state.roomName,
roomAvatar = state.roomAvatar,
onBackPressed = onBackPressed
)
},
content = { padding ->
MessagesViewContent(
state = state,
modifier = Modifier.padding(padding),
)
},
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.navigationBarsPadding()
)
},
)
fun onActionSelected(action: TimelineItemAction, messageEvent: MessagesTimelineItemState.MessageEvent) {
state.eventSink(MessagesEvents.HandleAction(action, messageEvent))
}
ActionListView(
state = state.actionListState,
modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = ::onActionSelected
)
}
@Composable
fun MessagesViewContent(
state: MessagesState,
modifier: Modifier = Modifier
) {
fun onMessageClicked(messageEvent: MessagesTimelineItemState.MessageEvent) {
Timber.v("OnMessageClicked= $messageEvent")
}
fun onMessageLongClicked(messageEvent: MessagesTimelineItemState.MessageEvent) {
Timber.v("OnMessageLongClicked= $messageEvent")
}
Column(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding()
) {
if (!state.composerState.isFullScreen) {
TimelineView(
state = state.timelineState,
modifier = Modifier.fillMaxWidth(),
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)
}
},
)
}
}
@Composable
fun MessagesViewTopBar(
roomTitle: String?,
roomAvatar: AvatarData?,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
TopAppBar(
modifier = modifier,
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (roomAvatar != null) {
Avatar(roomAvatar)
Spacer(modifier = Modifier.width(8.dp))
}
Text(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
text = roomTitle ?: "Unknown room",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
)
}

228
features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt

@ -1,228 +0,0 @@ @@ -1,228 +0,0 @@
/*
* Copyright (c) 2022 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.features.messages
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.Uninitialized
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesViewModel
import io.element.android.x.architecture.viewmodel.daggerMavericksViewModelFactory
import io.element.android.x.designsystem.components.avatar.AvatarSize
import io.element.android.x.di.SessionScope
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.content.MessagesTimelineItemRedactedContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
import io.element.android.x.matrix.MatrixClient
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 io.element.android.x.textcomposer.MessageComposerMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
private const val PAGINATION_COUNT = 50
@ContributesViewModel(SessionScope::class)
class MessagesViewModel @AssistedInject constructor(
private val client: MatrixClient,
@Assisted private val initialState: MessagesViewState
) :
MavericksViewModel<MessagesViewState>(initialState) {
companion object : MavericksViewModelFactory<MessagesViewModel, MessagesViewState> by daggerMavericksViewModelFactory()
private val matrixItemHelper = MatrixItemHelper(client)
private val room = client.getRoom(initialState.roomId)!!
private val messageTimelineItemStateFactory =
MessageTimelineItemStateFactory(matrixItemHelper, room, Dispatchers.Default)
private val timeline = room.timeline()
private val timelineCallback = object : MatrixTimeline.Callback {
override fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) {
viewModelScope.launch {
messageTimelineItemStateFactory.pushItem(timelineItem)
}
}
}
init {
handleInit()
}
fun loadMore() {
viewModelScope.launch {
timeline.paginateBackwards(PAGINATION_COUNT)
setState { copy(hasMoreToLoad = timeline.hasMoreToLoad) }
}
}
fun sendMessage(text: String) {
viewModelScope.launch {
val state = awaitState()
// Reset composer right away
setNormalMode()
when (state.composerMode) {
is MessageComposerMode.Normal -> timeline.sendMessage(text)
is MessageComposerMode.Edit -> timeline.editMessage(
state.composerMode.eventId,
text
)
is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> timeline.replyMessage(
state.composerMode.eventId,
text
)
}
}
}
suspend fun getTargetEvent(): MessagesTimelineItemState.MessageEvent? {
val currentState = awaitState()
return currentState.itemActionsSheetState.invoke()?.targetItem
}
fun handleItemAction(
action: MessagesItemAction,
targetEvent: MessagesTimelineItemState.MessageEvent
) {
viewModelScope.launch(Dispatchers.Default) {
when (action) {
MessagesItemAction.Copy -> notImplementedYet()
MessagesItemAction.Forward -> notImplementedYet()
MessagesItemAction.Redact -> handleActionRedact(targetEvent)
MessagesItemAction.Edit -> handleActionEdit(targetEvent)
MessagesItemAction.Reply -> handleActionReply(targetEvent)
}
}
}
fun setNormalMode() {
setComposerMode(MessageComposerMode.Normal(""))
}
fun onSnackbarShown() {
setSnackbarContent(null)
}
fun computeActionsSheetState(messagesTimelineItemState: MessagesTimelineItemState.MessageEvent?) {
if (messagesTimelineItemState == null) {
setState { copy(itemActionsSheetState = Uninitialized) }
return
}
suspend {
val actions =
if (messagesTimelineItemState.content is MessagesTimelineItemRedactedContent) {
emptyList()
} else {
mutableListOf(
MessagesItemAction.Reply,
MessagesItemAction.Forward,
MessagesItemAction.Copy,
).also {
if (messagesTimelineItemState.isMine) {
it.add(MessagesItemAction.Edit)
it.add(MessagesItemAction.Redact)
}
}
}
MessagesItemActionsSheetState(
targetItem = messagesTimelineItemState,
actions = actions
)
}.execute(Dispatchers.Default) {
copy(itemActionsSheetState = it)
}
}
private fun handleInit() {
timeline.initialize()
timeline.callback = timelineCallback
room.syncUpdateFlow()
.onEach {
val avatarData =
matrixItemHelper.loadAvatarData(
room = room,
size = AvatarSize.SMALL
)
setState {
copy(
roomName = room.name, roomAvatar = avatarData,
)
}
}.launchIn(viewModelScope)
timeline
.timelineItems()
.onEach(messageTimelineItemStateFactory::replaceWith)
.launchIn(viewModelScope)
messageTimelineItemStateFactory
.flow()
.execute {
copy(timelineItems = it)
}
}
private fun setSnackbarContent(message: String?) {
setState { copy(snackbarContent = message) }
}
private fun handleActionRedact(event: MessagesTimelineItemState.MessageEvent) {
viewModelScope.launch {
room.redactEvent(event.id)
}
}
private fun handleActionEdit(targetEvent: MessagesTimelineItemState.MessageEvent) {
setComposerMode(
MessageComposerMode.Edit(
targetEvent.id,
(targetEvent.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty()
)
)
}
private fun handleActionReply(targetEvent: MessagesTimelineItemState.MessageEvent) {
setComposerMode(MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, ""))
}
private fun setComposerMode(mode: MessageComposerMode) {
setState {
copy(
composerMode = mode,
highlightedEventId = mode.relatedEventId
)
}
}
private fun notImplementedYet() {
setSnackbarContent("Not implemented yet!")
}
override fun onCleared() {
super.onCleared()
timeline.callback = null
timeline.dispose()
}
}

8
features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListEvents.kt

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

59
features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListPresenter.kt

@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
package io.element.android.x.features.messages.actionlist
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
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 kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
@Composable
override fun present(): ActionListState {
val localCoroutineScope = rememberCoroutineScope()
val target: MutableState<ActionListState.Target> = remember {
mutableStateOf(ActionListState.Target.None)
}
fun handleEvents(event: ActionListEvents) {
when (event) {
ActionListEvents.Clear -> target.value = ActionListState.Target.None
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(event.messageEvent, target)
}
}
return ActionListState(
target = target.value,
eventSink = ::handleEvents
)
}
fun CoroutineScope.computeForMessage(messagesTimelineItemState: MessagesTimelineItemState.MessageEvent, target: MutableState<ActionListState.Target>) = launch {
target.value = ActionListState.Target.Loading(messagesTimelineItemState)
val actions =
if (messagesTimelineItemState.content is MessagesTimelineItemRedactedContent) {
emptyList()
} else {
mutableListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
).also {
if (messagesTimelineItemState.isMine) {
it.add(TimelineItemAction.Edit)
it.add(TimelineItemAction.Redact)
}
}
}
target.value = ActionListState.Target.Success(messagesTimelineItemState, actions.toImmutableList())
}
}

37
features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListState.kt

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
/*
* Copyright (c) 2022 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.features.messages.actionlist
import androidx.compose.runtime.Immutable
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class ActionListState(
val target: Target = Target.None,
val eventSink: (ActionListEvents) -> Unit = {},
) {
sealed interface Target {
object None : Target
data class Loading(val messageEvent: MessagesTimelineItemState.MessageEvent) : Target
data class Success(
val messageEvent: MessagesTimelineItemState.MessageEvent,
val actions: ImmutableList<TimelineItemAction>,
) : Target
}
}

116
features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListView.kt

@ -0,0 +1,116 @@ @@ -0,0 +1,116 @@
@file:OptIn(ExperimentalMaterialApi::class)
package io.element.android.x.features.messages.actionlist
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetLayout
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
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 kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@Composable
fun ActionListView(
state: ActionListState,
modalBottomSheetState: ModalBottomSheetState,
onActionSelected: (action: TimelineItemAction, MessagesTimelineItemState.MessageEvent) -> Unit,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(modalBottomSheetState) {
snapshotFlow { modalBottomSheetState.currentValue }
.filter { it == ModalBottomSheetValue.Hidden }
.collect {
state.eventSink(ActionListEvents.Clear)
}
}
fun onItemActionClicked(
itemAction: TimelineItemAction,
targetItem: MessagesTimelineItemState.MessageEvent
) {
onActionSelected(itemAction, targetItem)
coroutineScope.launch {
modalBottomSheetState.hide()
}
}
ModalBottomSheetLayout(
modifier = modifier,
sheetState = modalBottomSheetState,
sheetContent = {
SheetContent(
state = state,
onActionClicked = ::onItemActionClicked,
modifier = Modifier
.navigationBarsPadding()
.imePadding()
)
}
) {}
}
@Composable
private fun SheetContent(
state: ActionListState,
modifier: Modifier = Modifier,
onActionClicked: (TimelineItemAction, MessagesTimelineItemState.MessageEvent) -> Unit = { _, _ -> },
) {
when (val target = state.target) {
is ActionListState.Target.Loading,
ActionListState.Target.None -> {
// Crashes if sheetContent size is zero
Box(modifier = modifier.size(1.dp))
}
is ActionListState.Target.Success -> {
val actions = target.actions
LazyColumn(
modifier = modifier.fillMaxWidth()
) {
items(
items = actions,
) { action ->
ListItem(
modifier = Modifier.clickable {
onActionClicked(action, target.messageEvent)
},
text = {
Text(
text = action.title,
color = if (action.destructive) MaterialTheme.colors.error else Color.Unspecified,
)
},
icon = {
VectorIcon(
resourceId = action.icon,
tint = if (action.destructive) MaterialTheme.colors.error else LocalContentColor.current,
)
}
)
}
}
}
}
}

18
features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt → features/messages/src/main/java/io/element/android/x/features/messages/actionlist/TimelineItemAction.kt

@ -14,21 +14,21 @@ @@ -14,21 +14,21 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model
package io.element.android.x.features.messages.actionlist
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Stable
import androidx.compose.runtime.Immutable
import io.element.android.x.designsystem.VectorIcons
@Stable
sealed class MessagesItemAction(
@Immutable
sealed class TimelineItemAction(
val title: String,
@DrawableRes val icon: Int,
val destructive: Boolean = false
) {
object Forward : MessagesItemAction("Forward", VectorIcons.ArrowForward)
object Copy : MessagesItemAction("Copy", VectorIcons.Copy)
object Redact : MessagesItemAction("Redact", VectorIcons.Delete, destructive = true)
object Reply : MessagesItemAction("Reply", VectorIcons.Reply)
object Edit : MessagesItemAction("Edit", VectorIcons.Edit)
object Forward : TimelineItemAction("Forward", VectorIcons.ArrowForward)
object Copy : TimelineItemAction("Copy", VectorIcons.Copy)
object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true)
object Reply : TimelineItemAction("Reply", VectorIcons.Reply)
object Edit : TimelineItemAction("Edit", VectorIcons.Edit)
}

145
features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemActionsSheet.kt

@ -1,145 +0,0 @@ @@ -1,145 +0,0 @@
/*
* Copyright (c) 2022 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@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.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetLayout
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.airbnb.mvrx.compose.collectAsState
import io.element.android.x.designsystem.components.VectorIcon
import io.element.android.x.features.messages.MessagesViewModel
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.content.MessagesTimelineItemTextBasedContent
import io.element.android.x.features.messages.textcomposer.MessageComposerViewModel
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@Composable
fun TimelineItemActionsScreen(
viewModel: MessagesViewModel,
composerViewModel: MessageComposerViewModel,
modalBottomSheetState: ModalBottomSheetState,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(modalBottomSheetState) {
snapshotFlow { modalBottomSheetState.currentValue }
.filter { it == ModalBottomSheetValue.Hidden }
.collect {
viewModel.computeActionsSheetState(null)
}
}
val itemActionsSheetState by viewModel.collectAsState(MessagesViewState::itemActionsSheetState)
fun onItemActionClicked(
itemAction: MessagesItemAction,
targetItem: MessagesTimelineItemState.MessageEvent
) {
viewModel.handleItemAction(itemAction, targetItem)
coroutineScope.launch {
val targetEvent = viewModel.getTargetEvent()
when (itemAction) {
is MessagesItemAction.Edit -> {
// Entering Edit mode, update the text in the composer.
val newComposerText =
(targetEvent?.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty()
composerViewModel.updateText(newComposerText)
}
else -> Unit
}
modalBottomSheetState.hide()
}
}
ModalBottomSheetLayout(
modifier = modifier,
sheetState = modalBottomSheetState,
sheetContent = {
SheetContent(
actionsSheetState = itemActionsSheetState(),
onActionClicked = ::onItemActionClicked,
modifier = Modifier
.navigationBarsPadding()
.imePadding()
)
}
) {}
}
@Composable
private fun SheetContent(
actionsSheetState: MessagesItemActionsSheetState?,
modifier: Modifier = Modifier,
onActionClicked: (MessagesItemAction, MessagesTimelineItemState.MessageEvent) -> Unit = { _, _ -> },
) {
if (actionsSheetState == null || actionsSheetState.actions.isEmpty()) {
// Crashes if sheetContent size is zero
Box(modifier = modifier.size(1.dp))
} else {
LazyColumn(
modifier = modifier
.fillMaxWidth()
) {
items(actionsSheetState.actions) {
ListItem(
modifier = Modifier.clickable {
onActionClicked(it, actionsSheetState.targetItem)
},
text = {
Text(
text = it.title,
color = if (it.destructive) MaterialTheme.colors.error else Color.Unspecified,
)
},
icon = {
VectorIcon(
resourceId = it.icon,
tint = if (it.destructive) MaterialTheme.colors.error else LocalContentColor.current,
)
}
)
}
}
}
}

45
features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt

@ -1,45 +0,0 @@ @@ -1,45 +0,0 @@
/*
* Copyright (c) 2022 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.features.messages.model
import androidx.compose.runtime.Stable
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.textcomposer.MessageComposerMode
@Stable
data class MessagesViewState(
val roomId: String,
val roomName: String? = null,
val roomAvatar: AvatarData? = null,
val timelineItems: Async<List<MessagesTimelineItemState>> = Uninitialized,
val hasMoreToLoad: Boolean = true,
val itemActionsSheetState: Async<MessagesItemActionsSheetState> = Uninitialized,
val snackbarContent: String? = null,
val highlightedEventId: String? = null,
val composerMode: MessageComposerMode = MessageComposerMode.Normal(""),
) : MavericksState {
@Suppress("unused")
constructor(roomId: String) : this(
roomId = roomId,
roomName = null,
roomAvatar = null
)
}

11
features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerEvents.kt

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
package io.element.android.x.features.messages.textcomposer
import io.element.android.x.textcomposer.MessageComposerMode
sealed interface MessageComposerEvents {
object ToggleFullScreenState : MessageComposerEvents
data class SendMessage(val message: String) : MessageComposerEvents
object CloseSpecialMode : MessageComposerEvents
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
data class UpdateText(val text: CharSequence) : MessageComposerEvents
}

73
features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerPresenter.kt

@ -0,0 +1,73 @@ @@ -0,0 +1,73 @@
package io.element.android.x.features.messages.textcomposer
import androidx.compose.runtime.Composable
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.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
import kotlinx.coroutines.launch
import javax.inject.Inject
class MessageComposerPresenter @Inject constructor(
private val appCoroutineScope: CoroutineScope,
private val client: MatrixClient,
private val room: MatrixRoom
) : Presenter<MessageComposerState> {
@Composable
override fun present(): MessageComposerState {
val isFullScreen = rememberSaveable {
mutableStateOf(false)
}
val text: MutableState<CharSequence> = rememberSaveable {
mutableStateOf("")
}
val composerMode: MutableState<MessageComposerMode> = rememberSaveable {
mutableStateOf(MessageComposerMode.Normal(""))
}
fun handleEvents(event: MessageComposerEvents) {
when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
is MessageComposerEvents.UpdateText -> text.value = event.text
MessageComposerEvents.CloseSpecialMode -> composerMode.setToNormal()
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode)
is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode
}
}
return MessageComposerState(
text = text.value.toStableCharSequence(),
isFullScreen = isFullScreen.value,
mode = composerMode.value,
eventSink = ::handleEvents
)
}
private fun MutableState<MessageComposerMode>.setToNormal() {
value = MessageComposerMode.Normal("")
}
private fun CoroutineScope.sendMessage(text: String, composerMode: MutableState<MessageComposerMode>) = launch {
val capturedMode = composerMode.value
// Reset composer right away
composerMode.setToNormal()
when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(text)
is MessageComposerMode.Edit -> room.editMessage(
capturedMode.eventId,
text
)
is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> room.replyMessage(
capturedMode.eventId,
text
)
}
}
}

12
features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewState.kt → features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerState.kt

@ -16,12 +16,12 @@ @@ -16,12 +16,12 @@
package io.element.android.x.features.messages.textcomposer
import androidx.compose.runtime.Stable
import com.airbnb.mvrx.MavericksState
import androidx.compose.runtime.Immutable
import io.element.android.x.core.data.StableCharSequence
import io.element.android.x.textcomposer.MessageComposerMode
@Stable
data class MessageComposerViewState(
@Immutable
data class MessageComposerState(
// val roomId: String,
// val canSendMessage: CanSendStatus = CanSendStatus.Allowed,
val isSendButtonVisible: Boolean = false,
@ -32,4 +32,6 @@ data class MessageComposerViewState( @@ -32,4 +32,6 @@ data class MessageComposerViewState(
// val voiceBroadcastState: VoiceBroadcastState? = null,
val text: StableCharSequence? = null,
val isFullScreen: Boolean = false,
) : MavericksState
val mode: MessageComposerMode = MessageComposerMode.Normal(""),
val eventSink: (MessageComposerEvents) -> Unit = {}
)

40
features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerView.kt

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
package io.element.android.x.features.messages.textcomposer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.x.textcomposer.TextComposer
@Composable
fun MessageComposerView(
state: MessageComposerState,
modifier: Modifier
) {
fun onFullscreenToggle() {
state.eventSink(MessageComposerEvents.ToggleFullScreenState)
}
fun sendMessage(message: String) {
state.eventSink(MessageComposerEvents.SendMessage(message))
}
fun onCloseSpecialMode() {
state.eventSink(MessageComposerEvents.CloseSpecialMode)
}
fun onComposerTextChange(text: CharSequence) {
state.eventSink(MessageComposerEvents.UpdateText(text))
}
TextComposer(
onSendMessage = ::sendMessage,
fullscreen = state.isFullScreen,
onFullscreenToggle = ::onFullscreenToggle,
composerMode = state.mode,
onCloseSpecialMode = ::onCloseSpecialMode,
onComposerTextChange = ::onComposerTextChange,
composerCanSendMessage = state.isSendButtonVisible,
composerText = state.text?.charSequence?.toString(),
modifier = modifier
)
}

54
features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewModel.kt

@ -1,54 +0,0 @@ @@ -1,54 +0,0 @@
/*
* Copyright (c) 2022 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.features.messages.textcomposer
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesViewModel
import io.element.android.x.architecture.viewmodel.daggerMavericksViewModelFactory
import io.element.android.x.core.data.StableCharSequence
import io.element.android.x.di.SessionScope
import io.element.android.x.matrix.MatrixClient
@ContributesViewModel(SessionScope::class)
class MessageComposerViewModel @AssistedInject constructor(
private val client: MatrixClient,
@Assisted private val initialState: MessageComposerViewState
) : MavericksViewModel<MessageComposerViewState>(initialState) {
companion object :
MavericksViewModelFactory<MessageComposerViewModel, MessageComposerViewState> by daggerMavericksViewModelFactory()
fun onComposerFullScreenChange() {
setState {
copy(
isFullScreen = !isFullScreen
)
}
}
fun updateText(newText: CharSequence) {
setState {
copy(
text = StableCharSequence(newText),
isSendButtonVisible = newText.isNotEmpty(),
)
}
}
}

8
features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineEvents.kt

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
package io.element.android.x.features.messages.timeline
import io.element.android.x.matrix.core.EventId
sealed interface TimelineEvents {
object LoadMore : TimelineEvents
data class SetHighlightedEvent(val eventId: EventId?): TimelineEvents
}

97
features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelinePresenter.kt

@ -0,0 +1,97 @@ @@ -0,0 +1,97 @@
package io.element.android.x.features.messages.timeline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
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.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val PAGINATION_COUNT = 50
class TimelinePresenter @Inject constructor(
private val appCoroutineScope: CoroutineScope,
private val client: MatrixClient,
private val room: MatrixRoom
) : Presenter<TimelineState> {
private val timeline = room.timeline()
private val matrixItemHelper = MatrixItemHelper(client)
private val messageTimelineItemStateFactory =
MessageTimelineItemStateFactory(matrixItemHelper, room, Dispatchers.Default)
private class TimelineCallback(private val coroutineScope: CoroutineScope, private val messageTimelineItemStateFactory: MessageTimelineItemStateFactory) : MatrixTimeline.Callback {
override fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) {
coroutineScope.launch {
messageTimelineItemStateFactory.pushItem(timelineItem)
}
}
}
@Composable
override fun present(): TimelineState {
val localCoroutineScope = rememberCoroutineScope()
val hasMoreToLoad = rememberSaveable {
mutableStateOf(timeline.hasMoreToLoad)
}
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
mutableStateOf(null)
}
val timelineItems = messageTimelineItemStateFactory
.flow()
.collectAsState(emptyList())
fun handleEvents(event: TimelineEvents) {
when (event) {
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(hasMoreToLoad)
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
}
}
LaunchedEffect(Unit) {
timeline
.timelineItems()
.onEach(messageTimelineItemStateFactory::replaceWith)
.launchIn(this)
}
DisposableEffect(Unit) {
timeline.callback = TimelineCallback(localCoroutineScope, messageTimelineItemStateFactory)
timeline.initialize()
onDispose {
timeline.callback = null
timeline.dispose()
}
}
return TimelineState(
highlightedEventId = highlightedEventId.value,
timelineItems = Async.Success(timelineItems.value),
hasMoreToLoad = hasMoreToLoad.value,
eventSink = ::handleEvents
)
}
fun CoroutineScope.loadMore(hasMoreToLoad: MutableState<Boolean>) = launch {
timeline.paginateBackwards(PAGINATION_COUNT)
hasMoreToLoad.value = timeline.hasMoreToLoad
}
}

30
features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineState.kt

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
/*
* Copyright (c) 2022 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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.matrix.core.EventId
@Immutable
data class TimelineState(
val timelineItems: Async<List<MessagesTimelineItemState>> = Async.Uninitialized,
val hasMoreToLoad: Boolean = true,
val highlightedEventId: EventId? = null,
val eventSink: (TimelineEvents) -> Unit = {}
)

412
features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineView.kt

@ -0,0 +1,412 @@ @@ -0,0 +1,412 @@
package io.element.android.x.features.messages.timeline
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LastBaseline
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.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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@Composable
fun TimelineView(
state: TimelineState,
modifier: Modifier = Modifier,
onMessageClicked: (MessagesTimelineItemState.MessageEvent) -> Unit = {},
onMessageLongClicked: (MessagesTimelineItemState.MessageEvent) -> Unit = {},
) {
val lazyListState = rememberLazyListState()
val timelineItems = state.timelineItems.dataOrNull().orEmpty().toImmutableList()
Box(modifier = modifier.fillMaxWidth()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Bottom,
reverseLayout = true
) {
items(
items = timelineItems,
contentType = { timelineItem -> timelineItem.contentType() },
key = { timelineItem -> timelineItem.key() },
) { timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
isHighlighted = timelineItem.key() == state.highlightedEventId?.value,
onClick = onMessageClicked,
onLongClick = onMessageLongClicked
)
}
if (state.hasMoreToLoad) {
item {
TimelineLoadingMoreIndicator()
}
}
}
fun onReachedLoadMore() {
state.eventSink(TimelineEvents.LoadMore)
}
TimelineScrollHelper(
lazyListState = lazyListState,
timelineItems = timelineItems,
onLoadMore = ::onReachedLoadMore
)
}
}
private fun MessagesTimelineItemState.key(): String {
return when (this) {
is MessagesTimelineItemState.MessageEvent -> id
is MessagesTimelineItemState.Virtual -> id
}
}
private fun MessagesTimelineItemState.contentType(): Int {
return when (this) {
is MessagesTimelineItemState.MessageEvent -> 0
is MessagesTimelineItemState.Virtual -> 1
}
}
@Composable
fun TimelineItemRow(
timelineItem: MessagesTimelineItemState,
isHighlighted: Boolean,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
) {
when (timelineItem) {
is MessagesTimelineItemState.Virtual -> return
is MessagesTimelineItemState.MessageEvent -> MessageEventRow(
messageEvent = timelineItem,
isHighlighted = isHighlighted,
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) }
)
}
}
@Composable
fun MessageEventRow(
messageEvent: MessagesTimelineItemState.MessageEvent,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
val (parentAlignment, contentAlignment) = if (messageEvent.isMine) {
Pair(Alignment.CenterEnd, Alignment.End)
} else {
Pair(Alignment.CenterStart, Alignment.Start)
}
Box(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
contentAlignment = parentAlignment
) {
Row {
if (!messageEvent.isMine) {
Spacer(modifier = Modifier.width(16.dp))
}
Column(horizontalAlignment = contentAlignment) {
if (messageEvent.showSenderInformation) {
MessageSenderInformation(
messageEvent.safeSenderName,
messageEvent.senderAvatar,
Modifier.zIndex(1f)
)
}
MessageEventBubble(
groupPosition = messageEvent.groupPosition,
isMine = messageEvent.isMine,
interactionSource = interactionSource,
isHighlighted = isHighlighted,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.zIndex(-1f)
.widthIn(max = 320.dp)
) {
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
when (messageEvent.content) {
is MessagesTimelineItemEncryptedContent -> MessagesTimelineItemEncryptedView(
content = messageEvent.content,
modifier = contentModifier
)
is MessagesTimelineItemRedactedContent -> MessagesTimelineItemRedactedView(
content = messageEvent.content,
modifier = contentModifier
)
is MessagesTimelineItemTextBasedContent -> MessagesTimelineItemTextView(
content = messageEvent.content,
interactionSource = interactionSource,
modifier = contentModifier,
onTextClicked = onClick,
onTextLongClicked = onLongClick
)
is MessagesTimelineItemUnknownContent -> MessagesTimelineItemUnknownView(
content = messageEvent.content,
modifier = contentModifier
)
is MessagesTimelineItemImageContent -> MessagesTimelineItemImageView(
content = messageEvent.content,
modifier = contentModifier
)
}
}
MessagesReactionsView(
reactionsState = messageEvent.reactionsState,
modifier = Modifier
.zIndex(1f)
.offset(x = if (messageEvent.isMine) 0.dp else 20.dp, y = -(16.dp))
)
}
if (messageEvent.isMine) {
Spacer(modifier = Modifier.width(16.dp))
}
}
}
if (messageEvent.groupPosition.isNew()) {
Spacer(modifier = modifier.height(8.dp))
} else {
Spacer(modifier = modifier.height(2.dp))
}
}
@Composable
private fun MessageSenderInformation(
sender: String,
senderAvatar: AvatarData?,
modifier: Modifier = Modifier
) {
Row(modifier = modifier) {
if (senderAvatar != null) {
Avatar(senderAvatar)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = sender,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.alignBy(LastBaseline)
)
}
}
@Composable
internal fun BoxScope.TimelineScrollHelper(
lazyListState: LazyListState,
timelineItems: ImmutableList<MessagesTimelineItemState>,
onLoadMore: () -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
// Auto-scroll when new timeline items appear
LaunchedEffect(timelineItems, firstVisibleItemIndex) {
if (!lazyListState.isScrollInProgress &&
firstVisibleItemIndex < 2
) coroutineScope.launch {
lazyListState.animateScrollToItem(0)
}
}
// Handle load more preloading
val loadMore by remember {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
lastVisibleItemIndex > (totalItemsNumber - 30)
}
}
LaunchedEffect(loadMore) {
snapshotFlow { loadMore }
.distinctUntilChanged()
.collect {
onLoadMore()
}
}
// Jump to bottom button
if (firstVisibleItemIndex > 2) {
FloatingActionButton(
onClick = {
coroutineScope.launch {
if (firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0)
}
}
},
shape = CircleShape,
modifier = Modifier
.align(Alignment.BottomCenter)
.size(40.dp),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
) {
Icon(Icons.Default.ArrowDownward, "")
}
}
}
@Composable
internal fun TimelineLoadingMoreIndicator() {
Box(
Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
class MessagesItemGroupPositionToMessagesTimelineItemContentProvider :
PairCombinedPreviewParameter<MessagesItemGroupPosition, MessagesTimelineItemContent>(
MessagesItemGroupPositionProvider() to MessagesTimelineItemContentProvider()
)
@Suppress("PreviewPublic")
@Preview(showBackground = true)
@Composable
fun TimelineItemsPreview(
@PreviewParameter(MessagesTimelineItemContentProvider::class)
content: MessagesTimelineItemContent
) {
val timelineItems = persistentListOf(
// 3 items (First Middle Last) with isMine = false
createMessageEvent(
isMine = false,
content = content,
groupPosition = MessagesItemGroupPosition.First
),
createMessageEvent(
isMine = false,
content = content,
groupPosition = MessagesItemGroupPosition.Middle
),
createMessageEvent(
isMine = false,
content = content,
groupPosition = MessagesItemGroupPosition.Last
),
// 3 items (First Middle Last) with isMine = true
createMessageEvent(
isMine = true,
content = content,
groupPosition = MessagesItemGroupPosition.First
),
createMessageEvent(
isMine = true,
content = content,
groupPosition = MessagesItemGroupPosition.Middle
),
createMessageEvent(
isMine = true,
content = content,
groupPosition = MessagesItemGroupPosition.Last
),
)
TimelineView(
state = TimelineState(
timelineItems = Async.Success(timelineItems)
)
)
}
private fun createMessageEvent(
isMine: Boolean,
content: MessagesTimelineItemContent,
groupPosition: MessagesItemGroupPosition
): MessagesTimelineItemState {
return MessagesTimelineItemState.MessageEvent(
id = Math.random().toString(),
senderId = "senderId",
senderAvatar = AvatarData("sender"),
content = content,
reactionsState = MessagesItemReactionState(
listOf(
AggregatedReaction("👍", "1")
)
),
isMine = isMine,
senderDisplayName = "sender",
groupPosition = groupPosition,
)
}

2
features/messages/src/main/java/io/element/android/x/features/messages/components/MessageEventBubble.kt → features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessageEventBubble.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.components
package io.element.android.x.features.messages.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable

2
features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesReactionsView.kt → features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesReactionsView.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.components
package io.element.android.x.features.messages.timeline.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Row

2
features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemEncryptedView.kt → features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemEncryptedView.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.components
package io.element.android.x.features.messages.timeline.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning

2
features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemImageView.kt → features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemImageView.kt

@ -16,7 +16,7 @@ @@ -16,7 +16,7 @@
@file:OptIn(ExperimentalFoundationApi::class)
package io.element.android.x.features.messages.components
package io.element.android.x.features.messages.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box

2
features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemInformativeView.kt → features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemInformativeView.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.components
package io.element.android.x.features.messages.timeline.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer

3
features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemRedactedView.kt → features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemRedactedView.kt

@ -14,13 +14,14 @@ @@ -14,13 +14,14 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.components
package io.element.android.x.features.messages.timeline.components
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
@Composable
fun MessagesTimelineItemRedactedView(

4
features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt → features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemTextView.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.components
package io.element.android.x.features.messages.timeline.components
import android.text.SpannableString
import android.text.style.URLSpan
@ -30,7 +30,7 @@ import androidx.compose.ui.text.buildAnnotatedString @@ -30,7 +30,7 @@ import androidx.compose.ui.text.buildAnnotatedString
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.components.html.HtmlDocument
import io.element.android.x.features.messages.timeline.components.html.HtmlDocument
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
@Composable

2
features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemUnknownView.kt → features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemUnknownView.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.components
package io.element.android.x.features.messages.timeline.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info

2
features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt → features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/html/HtmlDocument.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.components.html
package io.element.android.x.features.messages.timeline.components.html
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource

10
features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemActionsSheetState.kt → libraries/di/src/main/java/io/element/android/x/di/RoomScope.kt

@ -14,12 +14,6 @@ @@ -14,12 +14,6 @@
* limitations under the License.
*/
package io.element.android.x.features.messages.model
package io.element.android.x.di
import androidx.compose.runtime.Stable
@Stable
data class MessagesItemActionsSheetState(
val targetItem: MessagesTimelineItemState.MessageEvent,
val actions: List<MessagesItemAction>
)
abstract class RoomScope private constructor()

2
libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt

@ -18,7 +18,6 @@ package io.element.android.x.matrix.timeline @@ -18,7 +18,6 @@ package io.element.android.x.matrix.timeline
import io.element.android.x.core.coroutine.CoroutineDispatchers
import io.element.android.x.matrix.room.MatrixRoom
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
@ -33,6 +32,7 @@ import org.matrix.rustcomponents.sdk.TimelineChange @@ -33,6 +32,7 @@ import org.matrix.rustcomponents.sdk.TimelineChange
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineListener
import timber.log.Timber
import java.util.Collections
class MatrixTimeline(
private val matrixRoom: MatrixRoom,

1
libraries/textcomposer/build.gradle.kts

@ -19,6 +19,7 @@ @@ -19,6 +19,7 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
id("kotlin-parcelize")
}
android {

9
libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt

@ -16,18 +16,25 @@ @@ -16,18 +16,25 @@
package io.element.android.x.textcomposer
sealed interface MessageComposerMode {
import android.os.Parcelable
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) :
MessageComposerMode
@Parcelize
data class Edit(override val eventId: String, override val defaultContent: CharSequence) :
Special(eventId, defaultContent)
@Parcelize
class Quote(override val eventId: String, override val defaultContent: CharSequence) :
Special(eventId, defaultContent)
@Parcelize
class Reply(
val senderName: String,
override val eventId: String,

Loading…
Cancel
Save