From 020fd3b4586393caf30ba1e160411e488d1bee8f Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 12 Jan 2023 18:19:27 +0100 Subject: [PATCH] Start migrating messages screen --- .../android/x/node/LoggedInFlowNode.kt | 14 +- .../io/element/android/x/node/RoomFlowNode.kt | 52 ++ .../x/features/messages/MessagesEvents.kt | 8 + .../x/features/messages/MessagesNode.kt | 40 + .../x/features/messages/MessagesPresenter.kt | 91 +++ .../x/features/messages/MessagesScreen.kt | 689 ------------------ .../x/features/messages/MessagesState.kt | 19 + .../x/features/messages/MessagesView.kt | 193 +++++ .../x/features/messages/MessagesViewModel.kt | 228 ------ .../messages/actionlist/ActionListEvents.kt | 8 + .../actionlist/ActionListPresenter.kt | 59 ++ .../messages/actionlist/ActionListState.kt | 37 + .../messages/actionlist/ActionListView.kt | 116 +++ .../TimelineItemAction.kt} | 18 +- .../MessagesTimelineItemActionsSheet.kt | 145 ---- .../messages/model/MessagesViewState.kt | 45 -- .../textcomposer/MessageComposerEvents.kt | 11 + .../textcomposer/MessageComposerPresenter.kt | 73 ++ ...erViewState.kt => MessageComposerState.kt} | 12 +- .../textcomposer/MessageComposerView.kt | 40 + .../textcomposer/MessageComposerViewModel.kt | 54 -- .../messages/timeline/TimelineEvents.kt | 8 + .../messages/timeline/TimelinePresenter.kt | 97 +++ .../messages/timeline/TimelineState.kt | 30 + .../messages/timeline/TimelineView.kt | 412 +++++++++++ .../components/MessageEventBubble.kt | 2 +- .../components/MessagesReactionsView.kt | 2 +- .../MessagesTimelineItemEncryptedView.kt | 2 +- .../MessagesTimelineItemImageView.kt | 2 +- .../MessagesTimelineItemInformativeView.kt | 2 +- .../MessagesTimelineItemRedactedView.kt | 3 +- .../MessagesTimelineItemTextView.kt | 4 +- .../MessagesTimelineItemUnknownView.kt | 2 +- .../components/html/HtmlDocument.kt | 2 +- .../java/io/element/android/x/di/RoomScope.kt | 10 +- .../x/matrix/timeline/MatrixTimeline.kt | 2 +- libraries/textcomposer/build.gradle.kts | 1 + .../x/textcomposer/MessageComposerMode.kt | 9 +- 38 files changed, 1337 insertions(+), 1205 deletions(-) create mode 100644 app/src/main/java/io/element/android/x/node/RoomFlowNode.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/MessagesEvents.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/MessagesNode.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/MessagesPresenter.kt delete mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/MessagesState.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/MessagesView.kt delete mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListEvents.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListPresenter.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListState.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListView.kt rename features/messages/src/main/java/io/element/android/x/features/messages/{model/MessagesItemAction.kt => actionlist/TimelineItemAction.kt} (64%) delete mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemActionsSheet.kt delete mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerEvents.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerPresenter.kt rename features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/{MessageComposerViewState.kt => MessageComposerState.kt} (82%) create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerView.kt delete mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewModel.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineEvents.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelinePresenter.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineState.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineView.kt rename features/messages/src/main/java/io/element/android/x/features/messages/{ => timeline}/components/MessageEventBubble.kt (98%) rename features/messages/src/main/java/io/element/android/x/features/messages/{ => timeline}/components/MessagesReactionsView.kt (97%) rename features/messages/src/main/java/io/element/android/x/features/messages/{ => timeline}/components/MessagesTimelineItemEncryptedView.kt (94%) rename features/messages/src/main/java/io/element/android/x/features/messages/{ => timeline}/components/MessagesTimelineItemImageView.kt (97%) rename features/messages/src/main/java/io/element/android/x/features/messages/{ => timeline}/components/MessagesTimelineItemInformativeView.kt (96%) rename features/messages/src/main/java/io/element/android/x/features/messages/{ => timeline}/components/MessagesTimelineItemRedactedView.kt (87%) rename features/messages/src/main/java/io/element/android/x/features/messages/{ => timeline}/components/MessagesTimelineItemTextView.kt (95%) rename features/messages/src/main/java/io/element/android/x/features/messages/{ => timeline}/components/MessagesTimelineItemUnknownView.kt (94%) rename features/messages/src/main/java/io/element/android/x/features/messages/{ => timeline}/components/html/HtmlDocument.kt (99%) rename 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 (70%) diff --git a/app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt b/app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt index 94fd29807b..ad8ad5743a 100644 --- a/app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt +++ b/app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt @@ -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( 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( 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( NavTarget.RoomList -> { createNode(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) diff --git a/app/src/main/java/io/element/android/x/node/RoomFlowNode.kt b/app/src/main/java/io/element/android/x/node/RoomFlowNode.kt new file mode 100644 index 0000000000..c7b5f21585 --- /dev/null +++ b/app/src/main/java/io/element/android/x/node/RoomFlowNode.kt @@ -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 = BackStack( + initialElement = NavTarget.Messages, + savedStateMap = buildContext.savedStateMap, + ), +) : ParentNode( + 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(buildContext) + } + } + + sealed interface NavTarget : Parcelable { + @Parcelize + object Messages : NavTarget + } + + @Composable + override fun View(modifier: Modifier) { + Children(navModel = backstack) + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesEvents.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesEvents.kt new file mode 100644 index 0000000000..b70c896e9f --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesEvents.kt @@ -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 +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesNode.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesNode.kt new file mode 100644 index 0000000000..25bd74ec97 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesNode.kt @@ -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, + //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") + } + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesPresenter.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesPresenter.kt new file mode 100644 index 0000000000..253bc3c1c1 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesPresenter.kt @@ -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 { + + @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) + ) + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt deleted file mode 100644 index 7d9b8146ae..0000000000 --- a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt +++ /dev/null @@ -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, - 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, - 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, - 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, - 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( - 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, - ) -} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesState.kt new file mode 100644 index 0000000000..a7fcefb88f --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesState.kt @@ -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 = {} +) diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesView.kt new file mode 100644 index 0000000000..6e8a761b28 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesView.kt @@ -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 + ) + } + } + + ) +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt deleted file mode 100644 index c7171b7754..0000000000 --- a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt +++ /dev/null @@ -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(initialState) { - - companion object : MavericksViewModelFactory 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() - } -} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListEvents.kt b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListEvents.kt new file mode 100644 index 0000000000..07554c88b0 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListEvents.kt @@ -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 +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListPresenter.kt b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListPresenter.kt new file mode 100644 index 0000000000..f8fac36a89 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListPresenter.kt @@ -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 { + + @Composable + override fun present(): ActionListState { + + val localCoroutineScope = rememberCoroutineScope() + + val target: MutableState = 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) = 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()) + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListState.kt new file mode 100644 index 0000000000..d81a794fa0 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListState.kt @@ -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, + ) : Target + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListView.kt new file mode 100644 index 0000000000..06a3824686 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/ActionListView.kt @@ -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, + ) + } + ) + } + } + } + } +} + diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/TimelineItemAction.kt similarity index 64% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/actionlist/TimelineItemAction.kt index 151f6be67a..36ce779ab3 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/actionlist/TimelineItemAction.kt @@ -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) } diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemActionsSheet.kt b/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemActionsSheet.kt deleted file mode 100644 index f5bdb70471..0000000000 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemActionsSheet.kt +++ /dev/null @@ -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, - ) - } - ) - } - } - } -} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt deleted file mode 100644 index 4441180ce0..0000000000 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt +++ /dev/null @@ -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> = Uninitialized, - val hasMoreToLoad: Boolean = true, - val itemActionsSheetState: Async = 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 - ) -} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerEvents.kt b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerEvents.kt new file mode 100644 index 0000000000..a558b88e81 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerEvents.kt @@ -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 +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerPresenter.kt b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerPresenter.kt new file mode 100644 index 0000000000..2c51a0199b --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerPresenter.kt @@ -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 { + + @Composable + override fun present(): MessageComposerState { + val isFullScreen = rememberSaveable { + mutableStateOf(false) + } + val text: MutableState = rememberSaveable { + mutableStateOf("") + } + val composerMode: MutableState = 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.setToNormal() { + value = MessageComposerMode.Normal("") + } + + private fun CoroutineScope.sendMessage(text: String, composerMode: MutableState) = 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 + ) + } + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerState.kt similarity index 82% rename from features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewState.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerState.kt index b85ce0da0c..dbd92f6139 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewState.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerState.kt @@ -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( // val voiceBroadcastState: VoiceBroadcastState? = null, val text: StableCharSequence? = null, val isFullScreen: Boolean = false, -) : MavericksState + val mode: MessageComposerMode = MessageComposerMode.Normal(""), + val eventSink: (MessageComposerEvents) -> Unit = {} +) diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerView.kt new file mode 100644 index 0000000000..74402a9e6d --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerView.kt @@ -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 + ) +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewModel.kt b/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewModel.kt deleted file mode 100644 index f4c83843dc..0000000000 --- a/features/messages/src/main/java/io/element/android/x/features/messages/textcomposer/MessageComposerViewModel.kt +++ /dev/null @@ -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(initialState) { - - companion object : - MavericksViewModelFactory by daggerMavericksViewModelFactory() - - fun onComposerFullScreenChange() { - setState { - copy( - isFullScreen = !isFullScreen - ) - } - } - - fun updateText(newText: CharSequence) { - setState { - copy( - text = StableCharSequence(newText), - isSendButtonVisible = newText.isNotEmpty(), - ) - } - } -} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineEvents.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineEvents.kt new file mode 100644 index 0000000000..5e978a38db --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineEvents.kt @@ -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 +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelinePresenter.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelinePresenter.kt new file mode 100644 index 0000000000..35167d82d8 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelinePresenter.kt @@ -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 { + + 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 = 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) = launch { + timeline.paginateBackwards(PAGINATION_COUNT) + hasMoreToLoad.value = timeline.hasMoreToLoad + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineState.kt new file mode 100644 index 0000000000..605bd43fb8 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineState.kt @@ -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> = Async.Uninitialized, + val hasMoreToLoad: Boolean = true, + val highlightedEventId: EventId? = null, + val eventSink: (TimelineEvents) -> Unit = {} +) diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineView.kt new file mode 100644 index 0000000000..b04cc2a1a3 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/TimelineView.kt @@ -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, + 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( + 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, + ) +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessageEventBubble.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessageEventBubble.kt similarity index 98% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessageEventBubble.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessageEventBubble.kt index 60f0c147a7..447e1bdd60 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessageEventBubble.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessageEventBubble.kt @@ -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 diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesReactionsView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesReactionsView.kt similarity index 97% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesReactionsView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesReactionsView.kt index 34d80f0a6e..14b5cf99e2 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesReactionsView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesReactionsView.kt @@ -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 diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemEncryptedView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemEncryptedView.kt similarity index 94% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemEncryptedView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemEncryptedView.kt index b5a2a7753b..cfccdfae6b 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemEncryptedView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemEncryptedView.kt @@ -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 diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemImageView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemImageView.kt similarity index 97% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemImageView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemImageView.kt index 9b0a270637..53647ff652 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemImageView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemImageView.kt @@ -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 diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemInformativeView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemInformativeView.kt similarity index 96% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemInformativeView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemInformativeView.kt index e2c775198f..200b17374a 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemInformativeView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemInformativeView.kt @@ -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 diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemRedactedView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemRedactedView.kt similarity index 87% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemRedactedView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemRedactedView.kt index 6ad7bb0772..e3ef130d50 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemRedactedView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemRedactedView.kt @@ -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( diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemTextView.kt similarity index 95% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemTextView.kt index 94f450e5c5..9bc80e0de5 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemTextView.kt @@ -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 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 diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemUnknownView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemUnknownView.kt similarity index 94% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemUnknownView.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemUnknownView.kt index 51021ab678..2b7ac2f85e 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemUnknownView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/MessagesTimelineItemUnknownView.kt @@ -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 diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/html/HtmlDocument.kt similarity index 99% rename from features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt rename to features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/html/HtmlDocument.kt index 56a7684a47..e5dcfc1112 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/timeline/components/html/HtmlDocument.kt @@ -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 diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemActionsSheetState.kt b/libraries/di/src/main/java/io/element/android/x/di/RoomScope.kt similarity index 70% rename from features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemActionsSheetState.kt rename to libraries/di/src/main/java/io/element/android/x/di/RoomScope.kt index 264fa85085..bcdb76c31d 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemActionsSheetState.kt +++ b/libraries/di/src/main/java/io/element/android/x/di/RoomScope.kt @@ -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 -) +abstract class RoomScope private constructor() diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt index 6bd1031ba7..a302574b8e 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt @@ -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 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, diff --git a/libraries/textcomposer/build.gradle.kts b/libraries/textcomposer/build.gradle.kts index 7ef94fcf8e..e5d8942511 100644 --- a/libraries/textcomposer/build.gradle.kts +++ b/libraries/textcomposer/build.gradle.kts @@ -19,6 +19,7 @@ plugins { id("io.element.android-compose-library") alias(libs.plugins.ksp) + id("kotlin-parcelize") } android { diff --git a/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt b/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt index bf5b556525..224409829c 100644 --- a/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt +++ b/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt @@ -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,