Start migrating messages screen
This commit is contained in:
parent
c2643d0967
commit
8718bc4cde
38 changed files with 1337 additions and 1205 deletions
|
|
@ -8,11 +8,8 @@ import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.node.ParentNode
|
import com.bumble.appyx.core.node.ParentNode
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import io.element.android.x.architecture.createNode
|
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.preferences.PreferencesFlowNode
|
||||||
import io.element.android.x.features.roomlist.RoomListNode
|
import io.element.android.x.features.roomlist.RoomListNode
|
||||||
import io.element.android.x.matrix.core.RoomId
|
import io.element.android.x.matrix.core.RoomId
|
||||||
|
|
@ -34,7 +31,7 @@ class LoggedInFlowNode(
|
||||||
|
|
||||||
private val roomListCallback = object : RoomListNode.Callback {
|
private val roomListCallback = object : RoomListNode.Callback {
|
||||||
override fun onRoomClicked(roomId: RoomId) {
|
override fun onRoomClicked(roomId: RoomId) {
|
||||||
backstack.push(NavTarget.Messages(roomId))
|
backstack.push(NavTarget.Room(roomId))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSettingsClicked() {
|
override fun onSettingsClicked() {
|
||||||
|
|
@ -47,7 +44,7 @@ class LoggedInFlowNode(
|
||||||
object RoomList : NavTarget
|
object RoomList : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class Messages(val roomId: RoomId) : NavTarget
|
data class Room(val roomId: RoomId) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object Settings : NavTarget
|
object Settings : NavTarget
|
||||||
|
|
@ -58,11 +55,8 @@ class LoggedInFlowNode(
|
||||||
NavTarget.RoomList -> {
|
NavTarget.RoomList -> {
|
||||||
createNode<RoomListNode>(buildContext, plugins = listOf(roomListCallback))
|
createNode<RoomListNode>(buildContext, plugins = listOf(roomListCallback))
|
||||||
}
|
}
|
||||||
is NavTarget.Messages -> viewModelSupportNode(buildContext) {
|
is NavTarget.Room -> {
|
||||||
MessagesScreen(
|
RoomFlowNode(buildContext, navTarget.roomId)
|
||||||
roomId = navTarget.roomId.value,
|
|
||||||
onBackPressed = { backstack.pop() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
NavTarget.Settings -> {
|
NavTarget.Settings -> {
|
||||||
PreferencesFlowNode(buildContext, onOpenBugReport)
|
PreferencesFlowNode(buildContext, onOpenBugReport)
|
||||||
|
|
|
||||||
52
app/src/main/java/io/element/android/x/node/RoomFlowNode.kt
Normal file
52
app/src/main/java/io/element/android/x/node/RoomFlowNode.kt
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package io.element.android.x.node
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.composable.Children
|
||||||
|
import com.bumble.appyx.core.lifecycle.subscribe
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.node.ParentNode
|
||||||
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
import io.element.android.x.architecture.createNode
|
||||||
|
import io.element.android.x.features.messages.MessagesNode
|
||||||
|
import io.element.android.x.matrix.core.RoomId
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class RoomFlowNode(
|
||||||
|
buildContext: BuildContext,
|
||||||
|
private val roomId: RoomId,
|
||||||
|
private val backstack: BackStack<NavTarget> = BackStack(
|
||||||
|
initialElement = NavTarget.Messages,
|
||||||
|
savedStateMap = buildContext.savedStateMap,
|
||||||
|
),
|
||||||
|
) : ParentNode<RoomFlowNode.NavTarget>(
|
||||||
|
navModel = backstack,
|
||||||
|
buildContext = buildContext
|
||||||
|
) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
lifecycle.subscribe(
|
||||||
|
onCreate = { Timber.v("OnCreate") },
|
||||||
|
onDestroy = { Timber.v("OnDestroy") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
return when (navTarget) {
|
||||||
|
NavTarget.Messages -> createNode<MessagesNode>(buildContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface NavTarget : Parcelable {
|
||||||
|
@Parcelize
|
||||||
|
object Messages : NavTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
Children(navModel = backstack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package io.element.android.x.features.messages
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.x.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.x.di.SessionScope
|
||||||
|
|
||||||
|
@ContributesNode(SessionScope::class)
|
||||||
|
class MessagesNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
//presenter: MessagesPresenter,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
/*
|
||||||
|
val state by connector.stateFlow.collectAsState()
|
||||||
|
MessagesView(
|
||||||
|
state = state,
|
||||||
|
onBackPressed = this::navigateUp,
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(text = "MESSAGES NODE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
package io.element.android.x.features.messages
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import io.element.android.x.architecture.Presenter
|
||||||
|
import io.element.android.x.features.messages.actionlist.ActionListPresenter
|
||||||
|
import io.element.android.x.features.messages.actionlist.TimelineItemAction
|
||||||
|
import io.element.android.x.features.messages.model.MessagesTimelineItemState
|
||||||
|
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
|
||||||
|
import io.element.android.x.features.messages.textcomposer.MessageComposerEvents
|
||||||
|
import io.element.android.x.features.messages.textcomposer.MessageComposerPresenter
|
||||||
|
import io.element.android.x.features.messages.textcomposer.MessageComposerState
|
||||||
|
import io.element.android.x.features.messages.timeline.TimelinePresenter
|
||||||
|
import io.element.android.x.matrix.MatrixClient
|
||||||
|
import io.element.android.x.matrix.core.RoomId
|
||||||
|
import io.element.android.x.matrix.room.MatrixRoom
|
||||||
|
import io.element.android.x.textcomposer.MessageComposerMode
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class MessagesPresenter @Inject constructor(
|
||||||
|
private val client: MatrixClient,
|
||||||
|
private val roomId: RoomId,
|
||||||
|
private val room: MatrixRoom,
|
||||||
|
private val composerPresenter: MessageComposerPresenter,
|
||||||
|
private val timelinePresenter: TimelinePresenter,
|
||||||
|
private val actionListPresenter: ActionListPresenter,
|
||||||
|
) : Presenter<MessagesState> {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): MessagesState {
|
||||||
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
val composerState = composerPresenter.present()
|
||||||
|
val timelineState = timelinePresenter.present()
|
||||||
|
val actionListState = actionListPresenter.present()
|
||||||
|
|
||||||
|
fun handleEvents(event: MessagesEvents) {
|
||||||
|
when (event) {
|
||||||
|
is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.messageEvent, composerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MessagesState(
|
||||||
|
roomId = roomId,
|
||||||
|
composerState = composerState,
|
||||||
|
timelineState = timelineState,
|
||||||
|
actionListState = actionListState,
|
||||||
|
eventSink = ::handleEvents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CoroutineScope.handleTimelineAction(
|
||||||
|
action: TimelineItemAction,
|
||||||
|
targetEvent: MessagesTimelineItemState.MessageEvent,
|
||||||
|
composerState: MessageComposerState,
|
||||||
|
) = launch {
|
||||||
|
when (action) {
|
||||||
|
TimelineItemAction.Copy -> notImplementedYet()
|
||||||
|
TimelineItemAction.Forward -> notImplementedYet()
|
||||||
|
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
||||||
|
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
|
||||||
|
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notImplementedYet() {
|
||||||
|
Timber.v("NotImplementedYet")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleActionRedact(event: MessagesTimelineItemState.MessageEvent) {
|
||||||
|
room.redactEvent(event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleActionEdit(targetEvent: MessagesTimelineItemState.MessageEvent, composerState: MessageComposerState) {
|
||||||
|
val composerMode = MessageComposerMode.Edit(
|
||||||
|
targetEvent.id,
|
||||||
|
(targetEvent.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty()
|
||||||
|
)
|
||||||
|
composerState.eventSink(
|
||||||
|
MessageComposerEvents.SetMode(composerMode)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleActionReply(targetEvent: MessagesTimelineItemState.MessageEvent, composerState: MessageComposerState) {
|
||||||
|
val composerMode = MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, "")
|
||||||
|
composerState.eventSink(
|
||||||
|
MessageComposerEvents.SetMode(composerMode)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,689 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(
|
|
||||||
ExperimentalMaterial3Api::class,
|
|
||||||
ExperimentalMaterialApi::class,
|
|
||||||
ExperimentalComposeUiApi::class
|
|
||||||
)
|
|
||||||
|
|
||||||
package io.element.android.x.features.messages
|
|
||||||
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
|
||||||
import androidx.compose.material.ModalBottomSheetValue
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.ArrowDownward
|
|
||||||
import androidx.compose.material.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Alignment.Companion.End
|
|
||||||
import androidx.compose.ui.Alignment.Companion.Start
|
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.layout.LastBaseline
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import com.airbnb.mvrx.compose.collectAsState
|
|
||||||
import com.airbnb.mvrx.compose.mavericksViewModel
|
|
||||||
import io.element.android.x.core.compose.LogCompositions
|
|
||||||
import io.element.android.x.core.compose.PairCombinedPreviewParameter
|
|
||||||
import io.element.android.x.core.data.StableCharSequence
|
|
||||||
import io.element.android.x.designsystem.components.avatar.Avatar
|
|
||||||
import io.element.android.x.designsystem.components.avatar.AvatarData
|
|
||||||
import io.element.android.x.features.messages.components.MessageEventBubble
|
|
||||||
import io.element.android.x.features.messages.components.MessagesReactionsView
|
|
||||||
import io.element.android.x.features.messages.components.MessagesTimelineItemEncryptedView
|
|
||||||
import io.element.android.x.features.messages.components.MessagesTimelineItemImageView
|
|
||||||
import io.element.android.x.features.messages.components.MessagesTimelineItemRedactedView
|
|
||||||
import io.element.android.x.features.messages.components.MessagesTimelineItemTextView
|
|
||||||
import io.element.android.x.features.messages.components.MessagesTimelineItemUnknownView
|
|
||||||
import io.element.android.x.features.messages.components.TimelineItemActionsScreen
|
|
||||||
import io.element.android.x.features.messages.model.AggregatedReaction
|
|
||||||
import io.element.android.x.features.messages.model.MessagesItemGroupPosition
|
|
||||||
import io.element.android.x.features.messages.model.MessagesItemGroupPositionProvider
|
|
||||||
import io.element.android.x.features.messages.model.MessagesItemReactionState
|
|
||||||
import io.element.android.x.features.messages.model.MessagesTimelineItemState
|
|
||||||
import io.element.android.x.features.messages.model.MessagesViewState
|
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemContent
|
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemContentProvider
|
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemEncryptedContent
|
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemImageContent
|
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
|
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
|
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemUnknownContent
|
|
||||||
import io.element.android.x.features.messages.textcomposer.MessageComposerViewModel
|
|
||||||
import io.element.android.x.features.messages.textcomposer.MessageComposerViewState
|
|
||||||
import io.element.android.x.textcomposer.MessageComposerMode
|
|
||||||
import io.element.android.x.textcomposer.TextComposer
|
|
||||||
import java.lang.Math.random
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MessagesScreen(
|
|
||||||
roomId: String,
|
|
||||||
onBackPressed: () -> Unit,
|
|
||||||
viewModel: MessagesViewModel = mavericksViewModel(argsFactory = { roomId }),
|
|
||||||
composerViewModel: MessageComposerViewModel = mavericksViewModel(argsFactory = { roomId })
|
|
||||||
) {
|
|
||||||
fun onSendMessage(textMessage: String) {
|
|
||||||
viewModel.sendMessage(textMessage)
|
|
||||||
composerViewModel.updateText("")
|
|
||||||
}
|
|
||||||
|
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
|
||||||
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
|
||||||
initialValue = ModalBottomSheetValue.Hidden,
|
|
||||||
)
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
val roomTitle by viewModel.collectAsState(MessagesViewState::roomName)
|
|
||||||
val roomAvatar by viewModel.collectAsState(MessagesViewState::roomAvatar)
|
|
||||||
val timelineItems by viewModel.collectAsState(MessagesViewState::timelineItems)
|
|
||||||
val hasMoreToLoad by viewModel.collectAsState(MessagesViewState::hasMoreToLoad)
|
|
||||||
val snackBarContent by viewModel.collectAsState(MessagesViewState::snackbarContent)
|
|
||||||
val composerMode by viewModel.collectAsState(MessagesViewState::composerMode)
|
|
||||||
val highlightedEventId by viewModel.collectAsState(MessagesViewState::highlightedEventId)
|
|
||||||
val composerFullScreen by composerViewModel.collectAsState(MessageComposerViewState::isFullScreen)
|
|
||||||
val composerCanSendMessage by composerViewModel.collectAsState(MessageComposerViewState::isSendButtonVisible)
|
|
||||||
val composerText by composerViewModel.collectAsState(MessageComposerViewState::text)
|
|
||||||
|
|
||||||
MessagesScreenContent(
|
|
||||||
roomTitle = roomTitle,
|
|
||||||
roomAvatar = roomAvatar,
|
|
||||||
timelineItems = timelineItems().orEmpty().toImmutableList(),
|
|
||||||
hasMoreToLoad = hasMoreToLoad,
|
|
||||||
onReachedLoadMore = viewModel::loadMore,
|
|
||||||
onBackPressed = onBackPressed,
|
|
||||||
onSendMessage = ::onSendMessage,
|
|
||||||
composerFullScreen = composerFullScreen,
|
|
||||||
onComposerFullScreenChange = composerViewModel::onComposerFullScreenChange,
|
|
||||||
onComposerTextChange = composerViewModel::updateText,
|
|
||||||
composerMode = composerMode,
|
|
||||||
highlightedEventId = highlightedEventId,
|
|
||||||
onCloseSpecialMode = viewModel::setNormalMode,
|
|
||||||
composerCanSendMessage = composerCanSendMessage,
|
|
||||||
composerText = composerText,
|
|
||||||
onClick = {
|
|
||||||
Timber.v("onClick on timeline item: ${it.id}")
|
|
||||||
},
|
|
||||||
onLongClick = {
|
|
||||||
focusManager.clearFocus(force = true)
|
|
||||||
viewModel.computeActionsSheetState(it)
|
|
||||||
coroutineScope.launch {
|
|
||||||
itemActionsBottomSheetState.show()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
snackbarHostState = snackbarHostState,
|
|
||||||
)
|
|
||||||
TimelineItemActionsScreen(
|
|
||||||
viewModel = viewModel,
|
|
||||||
composerViewModel = composerViewModel,
|
|
||||||
modalBottomSheetState = itemActionsBottomSheetState,
|
|
||||||
)
|
|
||||||
snackBarContent?.let {
|
|
||||||
coroutineScope.launch {
|
|
||||||
snackbarHostState.showSnackbar(it)
|
|
||||||
}
|
|
||||||
viewModel.onSnackbarShown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MessagesScreenContent(
|
|
||||||
roomTitle: String?,
|
|
||||||
roomAvatar: AvatarData?,
|
|
||||||
timelineItems: ImmutableList<MessagesTimelineItemState>,
|
|
||||||
hasMoreToLoad: Boolean,
|
|
||||||
onReachedLoadMore: () -> Unit,
|
|
||||||
onBackPressed: () -> Unit,
|
|
||||||
onSendMessage: (String) -> Unit,
|
|
||||||
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
|
||||||
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
|
||||||
composerFullScreen: Boolean,
|
|
||||||
onComposerFullScreenChange: () -> Unit,
|
|
||||||
onComposerTextChange: (CharSequence) -> Unit,
|
|
||||||
composerMode: MessageComposerMode,
|
|
||||||
highlightedEventId: String?,
|
|
||||||
onCloseSpecialMode: () -> Unit,
|
|
||||||
composerCanSendMessage: Boolean,
|
|
||||||
composerText: StableCharSequence?,
|
|
||||||
snackbarHostState: SnackbarHostState,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Content")
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
contentWindowInsets = WindowInsets.statusBars,
|
|
||||||
topBar = {
|
|
||||||
MessagesTopAppBar(
|
|
||||||
roomTitle = roomTitle,
|
|
||||||
roomAvatar = roomAvatar,
|
|
||||||
onBackPressed = onBackPressed
|
|
||||||
)
|
|
||||||
},
|
|
||||||
content = { padding ->
|
|
||||||
MessagesContent(
|
|
||||||
modifier = Modifier.padding(padding),
|
|
||||||
timelineItems = timelineItems,
|
|
||||||
hasMoreToLoad = hasMoreToLoad,
|
|
||||||
onReachedLoadMore = onReachedLoadMore,
|
|
||||||
onSendMessage = onSendMessage,
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
highlightedEventId = highlightedEventId,
|
|
||||||
composerMode = composerMode,
|
|
||||||
onCloseSpecialMode = onCloseSpecialMode,
|
|
||||||
composerFullScreen = composerFullScreen,
|
|
||||||
onComposerFullScreenChange = onComposerFullScreenChange,
|
|
||||||
onComposerTextChange = onComposerTextChange,
|
|
||||||
composerCanSendMessage = composerCanSendMessage,
|
|
||||||
composerText = composerText
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = {
|
|
||||||
SnackbarHost(
|
|
||||||
snackbarHostState,
|
|
||||||
modifier = Modifier.navigationBarsPadding()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MessagesContent(
|
|
||||||
timelineItems: ImmutableList<MessagesTimelineItemState>,
|
|
||||||
hasMoreToLoad: Boolean,
|
|
||||||
onReachedLoadMore: () -> Unit,
|
|
||||||
onSendMessage: (String) -> Unit,
|
|
||||||
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
|
||||||
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
|
||||||
composerMode: MessageComposerMode,
|
|
||||||
highlightedEventId: String?,
|
|
||||||
onCloseSpecialMode: () -> Unit,
|
|
||||||
composerFullScreen: Boolean,
|
|
||||||
onComposerFullScreenChange: () -> Unit,
|
|
||||||
onComposerTextChange: (CharSequence) -> Unit,
|
|
||||||
composerCanSendMessage: Boolean,
|
|
||||||
composerText: StableCharSequence?,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val lazyListState = rememberLazyListState()
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.imePadding()
|
|
||||||
) {
|
|
||||||
if (!composerFullScreen) {
|
|
||||||
TimelineItems(
|
|
||||||
lazyListState = lazyListState,
|
|
||||||
timelineItems = timelineItems,
|
|
||||||
highlightedEventId = highlightedEventId,
|
|
||||||
hasMoreToLoad = hasMoreToLoad,
|
|
||||||
onReachedLoadMore = onReachedLoadMore,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TextComposer(
|
|
||||||
onSendMessage = onSendMessage,
|
|
||||||
fullscreen = composerFullScreen,
|
|
||||||
onFullscreenToggle = onComposerFullScreenChange,
|
|
||||||
composerMode = composerMode,
|
|
||||||
onCloseSpecialMode = onCloseSpecialMode,
|
|
||||||
onComposerTextChange = onComposerTextChange,
|
|
||||||
composerCanSendMessage = composerCanSendMessage,
|
|
||||||
composerText = composerText?.charSequence?.toString(),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.let {
|
|
||||||
if (composerFullScreen) {
|
|
||||||
it.weight(1f, fill = false)
|
|
||||||
} else {
|
|
||||||
it.wrapContentHeight(Alignment.Bottom)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MessagesTopAppBar(
|
|
||||||
roomTitle: String?,
|
|
||||||
roomAvatar: AvatarData?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onBackPressed: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
TopAppBar(
|
|
||||||
modifier = modifier,
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBackPressed) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.ArrowBack,
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
if (roomAvatar != null) {
|
|
||||||
Avatar(roomAvatar)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
text = roomTitle ?: "Unknown room",
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TimelineItems(
|
|
||||||
lazyListState: LazyListState,
|
|
||||||
timelineItems: ImmutableList<MessagesTimelineItemState>,
|
|
||||||
highlightedEventId: String?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
hasMoreToLoad: Boolean = false,
|
|
||||||
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit = {},
|
|
||||||
onLongClick: ((MessagesTimelineItemState.MessageEvent)) -> Unit = {},
|
|
||||||
onReachedLoadMore: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
Box(modifier = modifier.fillMaxWidth()) {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
state = lazyListState,
|
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.Bottom,
|
|
||||||
reverseLayout = true
|
|
||||||
) {
|
|
||||||
items(
|
|
||||||
items = timelineItems,
|
|
||||||
contentType = { timelineItem -> timelineItem.contentType() },
|
|
||||||
key = { timelineItem -> timelineItem.key() },
|
|
||||||
) { timelineItem ->
|
|
||||||
TimelineItemRow(
|
|
||||||
timelineItem = timelineItem,
|
|
||||||
isHighlighted = timelineItem.key() == highlightedEventId,
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (hasMoreToLoad) {
|
|
||||||
item {
|
|
||||||
MessagesLoadingMoreIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MessagesScrollHelper(
|
|
||||||
lazyListState = lazyListState,
|
|
||||||
timelineItems = timelineItems,
|
|
||||||
onLoadMore = onReachedLoadMore
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun MessagesTimelineItemState.key(): String {
|
|
||||||
return when (this) {
|
|
||||||
is MessagesTimelineItemState.MessageEvent -> id
|
|
||||||
is MessagesTimelineItemState.Virtual -> id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun MessagesTimelineItemState.contentType(): Int {
|
|
||||||
return when (this) {
|
|
||||||
is MessagesTimelineItemState.MessageEvent -> 0
|
|
||||||
is MessagesTimelineItemState.Virtual -> 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TimelineItemRow(
|
|
||||||
timelineItem: MessagesTimelineItemState,
|
|
||||||
isHighlighted: Boolean,
|
|
||||||
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
|
||||||
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
|
||||||
) {
|
|
||||||
when (timelineItem) {
|
|
||||||
is MessagesTimelineItemState.Virtual -> return
|
|
||||||
is MessagesTimelineItemState.MessageEvent -> MessageEventRow(
|
|
||||||
messageEvent = timelineItem,
|
|
||||||
isHighlighted = isHighlighted,
|
|
||||||
onClick = { onClick(timelineItem) },
|
|
||||||
onLongClick = { onLongClick(timelineItem) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MessageEventRow(
|
|
||||||
messageEvent: MessagesTimelineItemState.MessageEvent,
|
|
||||||
isHighlighted: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onLongClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val (parentAlignment, contentAlignment) = if (messageEvent.isMine) {
|
|
||||||
Pair(Alignment.CenterEnd, End)
|
|
||||||
} else {
|
|
||||||
Pair(Alignment.CenterStart, Start)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.wrapContentHeight(),
|
|
||||||
contentAlignment = parentAlignment
|
|
||||||
) {
|
|
||||||
Row {
|
|
||||||
if (!messageEvent.isMine) {
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
}
|
|
||||||
Column(horizontalAlignment = contentAlignment) {
|
|
||||||
if (messageEvent.showSenderInformation) {
|
|
||||||
MessageSenderInformation(
|
|
||||||
messageEvent.safeSenderName,
|
|
||||||
messageEvent.senderAvatar,
|
|
||||||
Modifier.zIndex(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
MessageEventBubble(
|
|
||||||
groupPosition = messageEvent.groupPosition,
|
|
||||||
isMine = messageEvent.isMine,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
isHighlighted = isHighlighted,
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
modifier = Modifier
|
|
||||||
.zIndex(-1f)
|
|
||||||
.widthIn(max = 320.dp)
|
|
||||||
) {
|
|
||||||
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
|
||||||
when (messageEvent.content) {
|
|
||||||
is MessagesTimelineItemEncryptedContent -> MessagesTimelineItemEncryptedView(
|
|
||||||
content = messageEvent.content,
|
|
||||||
modifier = contentModifier
|
|
||||||
)
|
|
||||||
is MessagesTimelineItemRedactedContent -> MessagesTimelineItemRedactedView(
|
|
||||||
content = messageEvent.content,
|
|
||||||
modifier = contentModifier
|
|
||||||
)
|
|
||||||
is MessagesTimelineItemTextBasedContent -> MessagesTimelineItemTextView(
|
|
||||||
content = messageEvent.content,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
modifier = contentModifier,
|
|
||||||
onTextClicked = onClick,
|
|
||||||
onTextLongClicked = onLongClick
|
|
||||||
)
|
|
||||||
is MessagesTimelineItemUnknownContent -> MessagesTimelineItemUnknownView(
|
|
||||||
content = messageEvent.content,
|
|
||||||
modifier = contentModifier
|
|
||||||
)
|
|
||||||
is MessagesTimelineItemImageContent -> MessagesTimelineItemImageView(
|
|
||||||
content = messageEvent.content,
|
|
||||||
modifier = contentModifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MessagesReactionsView(
|
|
||||||
reactionsState = messageEvent.reactionsState,
|
|
||||||
modifier = Modifier
|
|
||||||
.zIndex(1f)
|
|
||||||
.offset(x = if (messageEvent.isMine) 0.dp else 20.dp, y = -(16.dp))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (messageEvent.isMine) {
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (messageEvent.groupPosition.isNew()) {
|
|
||||||
Spacer(modifier = modifier.height(8.dp))
|
|
||||||
} else {
|
|
||||||
Spacer(modifier = modifier.height(2.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MessageSenderInformation(
|
|
||||||
sender: String,
|
|
||||||
senderAvatar: AvatarData?,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Row(modifier = modifier) {
|
|
||||||
if (senderAvatar != null) {
|
|
||||||
Avatar(senderAvatar)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = sender,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier
|
|
||||||
.alignBy(LastBaseline)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun BoxScope.MessagesScrollHelper(
|
|
||||||
lazyListState: LazyListState,
|
|
||||||
timelineItems: ImmutableList<MessagesTimelineItemState>,
|
|
||||||
onLoadMore: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
|
|
||||||
|
|
||||||
// Auto-scroll when new timeline items appear
|
|
||||||
LaunchedEffect(timelineItems, firstVisibleItemIndex) {
|
|
||||||
if (!lazyListState.isScrollInProgress &&
|
|
||||||
firstVisibleItemIndex < 2
|
|
||||||
) coroutineScope.launch {
|
|
||||||
lazyListState.animateScrollToItem(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle load more preloading
|
|
||||||
val loadMore by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
val layoutInfo = lazyListState.layoutInfo
|
|
||||||
val totalItemsNumber = layoutInfo.totalItemsCount
|
|
||||||
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
|
||||||
lastVisibleItemIndex > (totalItemsNumber - 30)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(loadMore) {
|
|
||||||
snapshotFlow { loadMore }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect {
|
|
||||||
onLoadMore()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Jump to bottom button
|
|
||||||
if (firstVisibleItemIndex > 2) {
|
|
||||||
FloatingActionButton(
|
|
||||||
onClick = {
|
|
||||||
coroutineScope.launch {
|
|
||||||
if (firstVisibleItemIndex > 10) {
|
|
||||||
lazyListState.scrollToItem(0)
|
|
||||||
} else {
|
|
||||||
lazyListState.animateScrollToItem(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shape = CircleShape,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.size(40.dp),
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.ArrowDownward, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun MessagesLoadingMoreIndicator() {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.wrapContentHeight()
|
|
||||||
.padding(8.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MessagesItemGroupPositionToMessagesTimelineItemContentProvider :
|
|
||||||
PairCombinedPreviewParameter<MessagesItemGroupPosition, MessagesTimelineItemContent>(
|
|
||||||
MessagesItemGroupPositionProvider() to MessagesTimelineItemContentProvider()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Suppress("PreviewPublic")
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun TimelineItemsPreview(
|
|
||||||
@PreviewParameter(MessagesTimelineItemContentProvider::class)
|
|
||||||
content: MessagesTimelineItemContent
|
|
||||||
) {
|
|
||||||
TimelineItems(
|
|
||||||
lazyListState = LazyListState(),
|
|
||||||
timelineItems = persistentListOf(
|
|
||||||
// 3 items (First Middle Last) with isMine = false
|
|
||||||
createMessageEvent(
|
|
||||||
isMine = false,
|
|
||||||
content = content,
|
|
||||||
groupPosition = MessagesItemGroupPosition.First
|
|
||||||
),
|
|
||||||
createMessageEvent(
|
|
||||||
isMine = false,
|
|
||||||
content = content,
|
|
||||||
groupPosition = MessagesItemGroupPosition.Middle
|
|
||||||
),
|
|
||||||
createMessageEvent(
|
|
||||||
isMine = false,
|
|
||||||
content = content,
|
|
||||||
groupPosition = MessagesItemGroupPosition.Last
|
|
||||||
),
|
|
||||||
// 3 items (First Middle Last) with isMine = true
|
|
||||||
createMessageEvent(
|
|
||||||
isMine = true,
|
|
||||||
content = content,
|
|
||||||
groupPosition = MessagesItemGroupPosition.First
|
|
||||||
),
|
|
||||||
createMessageEvent(
|
|
||||||
isMine = true,
|
|
||||||
content = content,
|
|
||||||
groupPosition = MessagesItemGroupPosition.Middle
|
|
||||||
),
|
|
||||||
createMessageEvent(
|
|
||||||
isMine = true,
|
|
||||||
content = content,
|
|
||||||
groupPosition = MessagesItemGroupPosition.Last
|
|
||||||
),
|
|
||||||
),
|
|
||||||
highlightedEventId = null,
|
|
||||||
hasMoreToLoad = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createMessageEvent(
|
|
||||||
isMine: Boolean,
|
|
||||||
content: MessagesTimelineItemContent,
|
|
||||||
groupPosition: MessagesItemGroupPosition
|
|
||||||
): MessagesTimelineItemState {
|
|
||||||
return MessagesTimelineItemState.MessageEvent(
|
|
||||||
id = random().toString(),
|
|
||||||
senderId = "senderId",
|
|
||||||
senderAvatar = AvatarData("sender"),
|
|
||||||
content = content,
|
|
||||||
reactionsState = MessagesItemReactionState(
|
|
||||||
listOf(
|
|
||||||
AggregatedReaction("👍", "1")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
isMine = isMine,
|
|
||||||
senderDisplayName = "sender",
|
|
||||||
groupPosition = groupPosition,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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 = {}
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.x.features.messages
|
|
||||||
|
|
||||||
import com.airbnb.mvrx.MavericksViewModel
|
|
||||||
import com.airbnb.mvrx.MavericksViewModelFactory
|
|
||||||
import com.airbnb.mvrx.Uninitialized
|
|
||||||
import dagger.assisted.Assisted
|
|
||||||
import dagger.assisted.AssistedInject
|
|
||||||
import io.element.android.x.anvilannotations.ContributesViewModel
|
|
||||||
import io.element.android.x.architecture.viewmodel.daggerMavericksViewModelFactory
|
|
||||||
import io.element.android.x.designsystem.components.avatar.AvatarSize
|
|
||||||
import io.element.android.x.di.SessionScope
|
|
||||||
import io.element.android.x.features.messages.model.MessagesItemAction
|
|
||||||
import io.element.android.x.features.messages.model.MessagesItemActionsSheetState
|
|
||||||
import io.element.android.x.features.messages.model.MessagesTimelineItemState
|
|
||||||
import io.element.android.x.features.messages.model.MessagesViewState
|
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
|
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
|
|
||||||
import io.element.android.x.matrix.MatrixClient
|
|
||||||
import io.element.android.x.matrix.timeline.MatrixTimeline
|
|
||||||
import io.element.android.x.matrix.timeline.MatrixTimelineItem
|
|
||||||
import io.element.android.x.matrix.ui.MatrixItemHelper
|
|
||||||
import io.element.android.x.textcomposer.MessageComposerMode
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
private const val PAGINATION_COUNT = 50
|
|
||||||
|
|
||||||
@ContributesViewModel(SessionScope::class)
|
|
||||||
class MessagesViewModel @AssistedInject constructor(
|
|
||||||
private val client: MatrixClient,
|
|
||||||
@Assisted private val initialState: MessagesViewState
|
|
||||||
) :
|
|
||||||
MavericksViewModel<MessagesViewState>(initialState) {
|
|
||||||
|
|
||||||
companion object : MavericksViewModelFactory<MessagesViewModel, MessagesViewState> by daggerMavericksViewModelFactory()
|
|
||||||
|
|
||||||
private val matrixItemHelper = MatrixItemHelper(client)
|
|
||||||
private val room = client.getRoom(initialState.roomId)!!
|
|
||||||
private val messageTimelineItemStateFactory =
|
|
||||||
MessageTimelineItemStateFactory(matrixItemHelper, room, Dispatchers.Default)
|
|
||||||
private val timeline = room.timeline()
|
|
||||||
|
|
||||||
private val timelineCallback = object : MatrixTimeline.Callback {
|
|
||||||
override fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
messageTimelineItemStateFactory.pushItem(timelineItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
handleInit()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadMore() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
timeline.paginateBackwards(PAGINATION_COUNT)
|
|
||||||
setState { copy(hasMoreToLoad = timeline.hasMoreToLoad) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendMessage(text: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val state = awaitState()
|
|
||||||
// Reset composer right away
|
|
||||||
setNormalMode()
|
|
||||||
when (state.composerMode) {
|
|
||||||
is MessageComposerMode.Normal -> timeline.sendMessage(text)
|
|
||||||
is MessageComposerMode.Edit -> timeline.editMessage(
|
|
||||||
state.composerMode.eventId,
|
|
||||||
text
|
|
||||||
)
|
|
||||||
is MessageComposerMode.Quote -> TODO()
|
|
||||||
is MessageComposerMode.Reply -> timeline.replyMessage(
|
|
||||||
state.composerMode.eventId,
|
|
||||||
text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getTargetEvent(): MessagesTimelineItemState.MessageEvent? {
|
|
||||||
val currentState = awaitState()
|
|
||||||
return currentState.itemActionsSheetState.invoke()?.targetItem
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleItemAction(
|
|
||||||
action: MessagesItemAction,
|
|
||||||
targetEvent: MessagesTimelineItemState.MessageEvent
|
|
||||||
) {
|
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
|
||||||
when (action) {
|
|
||||||
MessagesItemAction.Copy -> notImplementedYet()
|
|
||||||
MessagesItemAction.Forward -> notImplementedYet()
|
|
||||||
MessagesItemAction.Redact -> handleActionRedact(targetEvent)
|
|
||||||
MessagesItemAction.Edit -> handleActionEdit(targetEvent)
|
|
||||||
MessagesItemAction.Reply -> handleActionReply(targetEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setNormalMode() {
|
|
||||||
setComposerMode(MessageComposerMode.Normal(""))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onSnackbarShown() {
|
|
||||||
setSnackbarContent(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun computeActionsSheetState(messagesTimelineItemState: MessagesTimelineItemState.MessageEvent?) {
|
|
||||||
if (messagesTimelineItemState == null) {
|
|
||||||
setState { copy(itemActionsSheetState = Uninitialized) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
suspend {
|
|
||||||
val actions =
|
|
||||||
if (messagesTimelineItemState.content is MessagesTimelineItemRedactedContent) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
mutableListOf(
|
|
||||||
MessagesItemAction.Reply,
|
|
||||||
MessagesItemAction.Forward,
|
|
||||||
MessagesItemAction.Copy,
|
|
||||||
).also {
|
|
||||||
if (messagesTimelineItemState.isMine) {
|
|
||||||
it.add(MessagesItemAction.Edit)
|
|
||||||
it.add(MessagesItemAction.Redact)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MessagesItemActionsSheetState(
|
|
||||||
targetItem = messagesTimelineItemState,
|
|
||||||
actions = actions
|
|
||||||
)
|
|
||||||
}.execute(Dispatchers.Default) {
|
|
||||||
copy(itemActionsSheetState = it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleInit() {
|
|
||||||
timeline.initialize()
|
|
||||||
timeline.callback = timelineCallback
|
|
||||||
room.syncUpdateFlow()
|
|
||||||
.onEach {
|
|
||||||
val avatarData =
|
|
||||||
matrixItemHelper.loadAvatarData(
|
|
||||||
room = room,
|
|
||||||
size = AvatarSize.SMALL
|
|
||||||
)
|
|
||||||
setState {
|
|
||||||
copy(
|
|
||||||
roomName = room.name, roomAvatar = avatarData,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.launchIn(viewModelScope)
|
|
||||||
|
|
||||||
timeline
|
|
||||||
.timelineItems()
|
|
||||||
.onEach(messageTimelineItemStateFactory::replaceWith)
|
|
||||||
.launchIn(viewModelScope)
|
|
||||||
|
|
||||||
messageTimelineItemStateFactory
|
|
||||||
.flow()
|
|
||||||
.execute {
|
|
||||||
copy(timelineItems = it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setSnackbarContent(message: String?) {
|
|
||||||
setState { copy(snackbarContent = message) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleActionRedact(event: MessagesTimelineItemState.MessageEvent) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
room.redactEvent(event.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleActionEdit(targetEvent: MessagesTimelineItemState.MessageEvent) {
|
|
||||||
setComposerMode(
|
|
||||||
MessageComposerMode.Edit(
|
|
||||||
targetEvent.id,
|
|
||||||
(targetEvent.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleActionReply(targetEvent: MessagesTimelineItemState.MessageEvent) {
|
|
||||||
setComposerMode(MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setComposerMode(mode: MessageComposerMode) {
|
|
||||||
setState {
|
|
||||||
copy(
|
|
||||||
composerMode = mode,
|
|
||||||
highlightedEventId = mode.relatedEventId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun notImplementedYet() {
|
|
||||||
setSnackbarContent("Not implemented yet!")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
timeline.callback = null
|
|
||||||
timeline.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
package io.element.android.x.features.messages.actionlist
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import io.element.android.x.architecture.Presenter
|
||||||
|
import io.element.android.x.features.messages.model.MessagesTimelineItemState
|
||||||
|
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): ActionListState {
|
||||||
|
|
||||||
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val target: MutableState<ActionListState.Target> = remember {
|
||||||
|
mutableStateOf(ActionListState.Target.None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvents(event: ActionListEvents) {
|
||||||
|
when (event) {
|
||||||
|
ActionListEvents.Clear -> target.value = ActionListState.Target.None
|
||||||
|
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(event.messageEvent, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActionListState(
|
||||||
|
target = target.value,
|
||||||
|
eventSink = ::handleEvents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CoroutineScope.computeForMessage(messagesTimelineItemState: MessagesTimelineItemState.MessageEvent, target: MutableState<ActionListState.Target>) = launch {
|
||||||
|
target.value = ActionListState.Target.Loading(messagesTimelineItemState)
|
||||||
|
val actions =
|
||||||
|
if (messagesTimelineItemState.content is MessagesTimelineItemRedactedContent) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
mutableListOf(
|
||||||
|
TimelineItemAction.Reply,
|
||||||
|
TimelineItemAction.Forward,
|
||||||
|
TimelineItemAction.Copy,
|
||||||
|
).also {
|
||||||
|
if (messagesTimelineItemState.isMine) {
|
||||||
|
it.add(TimelineItemAction.Edit)
|
||||||
|
it.add(TimelineItemAction.Redact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target.value = ActionListState.Target.Success(messagesTimelineItemState, actions.toImmutableList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.x.features.messages.actionlist
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import io.element.android.x.features.messages.model.MessagesTimelineItemState
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ActionListState(
|
||||||
|
val target: Target = Target.None,
|
||||||
|
val eventSink: (ActionListEvents) -> Unit = {},
|
||||||
|
) {
|
||||||
|
|
||||||
|
sealed interface Target {
|
||||||
|
object None : Target
|
||||||
|
data class Loading(val messageEvent: MessagesTimelineItemState.MessageEvent) : Target
|
||||||
|
data class Success(
|
||||||
|
val messageEvent: MessagesTimelineItemState.MessageEvent,
|
||||||
|
val actions: ImmutableList<TimelineItemAction>,
|
||||||
|
) : Target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -14,21 +14,21 @@
|
||||||
* limitations under the License.
|
* 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.annotation.DrawableRes
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.x.designsystem.VectorIcons
|
import io.element.android.x.designsystem.VectorIcons
|
||||||
|
|
||||||
@Stable
|
@Immutable
|
||||||
sealed class MessagesItemAction(
|
sealed class TimelineItemAction(
|
||||||
val title: String,
|
val title: String,
|
||||||
@DrawableRes val icon: Int,
|
@DrawableRes val icon: Int,
|
||||||
val destructive: Boolean = false
|
val destructive: Boolean = false
|
||||||
) {
|
) {
|
||||||
object Forward : MessagesItemAction("Forward", VectorIcons.ArrowForward)
|
object Forward : TimelineItemAction("Forward", VectorIcons.ArrowForward)
|
||||||
object Copy : MessagesItemAction("Copy", VectorIcons.Copy)
|
object Copy : TimelineItemAction("Copy", VectorIcons.Copy)
|
||||||
object Redact : MessagesItemAction("Redact", VectorIcons.Delete, destructive = true)
|
object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true)
|
||||||
object Reply : MessagesItemAction("Reply", VectorIcons.Reply)
|
object Reply : TimelineItemAction("Reply", VectorIcons.Reply)
|
||||||
object Edit : MessagesItemAction("Edit", VectorIcons.Edit)
|
object Edit : TimelineItemAction("Edit", VectorIcons.Edit)
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.x.features.messages.model
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
|
||||||
import com.airbnb.mvrx.Async
|
|
||||||
import com.airbnb.mvrx.MavericksState
|
|
||||||
import com.airbnb.mvrx.Uninitialized
|
|
||||||
import io.element.android.x.designsystem.components.avatar.AvatarData
|
|
||||||
import io.element.android.x.textcomposer.MessageComposerMode
|
|
||||||
|
|
||||||
@Stable
|
|
||||||
data class MessagesViewState(
|
|
||||||
val roomId: String,
|
|
||||||
val roomName: String? = null,
|
|
||||||
val roomAvatar: AvatarData? = null,
|
|
||||||
val timelineItems: Async<List<MessagesTimelineItemState>> = Uninitialized,
|
|
||||||
val hasMoreToLoad: Boolean = true,
|
|
||||||
val itemActionsSheetState: Async<MessagesItemActionsSheetState> = Uninitialized,
|
|
||||||
val snackbarContent: String? = null,
|
|
||||||
val highlightedEventId: String? = null,
|
|
||||||
val composerMode: MessageComposerMode = MessageComposerMode.Normal(""),
|
|
||||||
) : MavericksState {
|
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
constructor(roomId: String) : this(
|
|
||||||
roomId = roomId,
|
|
||||||
roomName = null,
|
|
||||||
roomAvatar = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
package io.element.android.x.features.messages.textcomposer
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import io.element.android.x.architecture.Presenter
|
||||||
|
import io.element.android.x.core.data.toStableCharSequence
|
||||||
|
import io.element.android.x.matrix.MatrixClient
|
||||||
|
import io.element.android.x.matrix.room.MatrixRoom
|
||||||
|
import io.element.android.x.textcomposer.MessageComposerMode
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class MessageComposerPresenter @Inject constructor(
|
||||||
|
private val appCoroutineScope: CoroutineScope,
|
||||||
|
private val client: MatrixClient,
|
||||||
|
private val room: MatrixRoom
|
||||||
|
) : Presenter<MessageComposerState> {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): MessageComposerState {
|
||||||
|
val isFullScreen = rememberSaveable {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
val text: MutableState<CharSequence> = rememberSaveable {
|
||||||
|
mutableStateOf("")
|
||||||
|
}
|
||||||
|
val composerMode: MutableState<MessageComposerMode> = rememberSaveable {
|
||||||
|
mutableStateOf(MessageComposerMode.Normal(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvents(event: MessageComposerEvents) {
|
||||||
|
when (event) {
|
||||||
|
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
||||||
|
is MessageComposerEvents.UpdateText -> text.value = event.text
|
||||||
|
MessageComposerEvents.CloseSpecialMode -> composerMode.setToNormal()
|
||||||
|
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode)
|
||||||
|
is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MessageComposerState(
|
||||||
|
text = text.value.toStableCharSequence(),
|
||||||
|
isFullScreen = isFullScreen.value,
|
||||||
|
mode = composerMode.value,
|
||||||
|
eventSink = ::handleEvents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableState<MessageComposerMode>.setToNormal() {
|
||||||
|
value = MessageComposerMode.Normal("")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.sendMessage(text: String, composerMode: MutableState<MessageComposerMode>) = launch {
|
||||||
|
val capturedMode = composerMode.value
|
||||||
|
// Reset composer right away
|
||||||
|
composerMode.setToNormal()
|
||||||
|
when (capturedMode) {
|
||||||
|
is MessageComposerMode.Normal -> room.sendMessage(text)
|
||||||
|
is MessageComposerMode.Edit -> room.editMessage(
|
||||||
|
capturedMode.eventId,
|
||||||
|
text
|
||||||
|
)
|
||||||
|
is MessageComposerMode.Quote -> TODO()
|
||||||
|
is MessageComposerMode.Reply -> room.replyMessage(
|
||||||
|
capturedMode.eventId,
|
||||||
|
text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,12 +16,12 @@
|
||||||
|
|
||||||
package io.element.android.x.features.messages.textcomposer
|
package io.element.android.x.features.messages.textcomposer
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.airbnb.mvrx.MavericksState
|
|
||||||
import io.element.android.x.core.data.StableCharSequence
|
import io.element.android.x.core.data.StableCharSequence
|
||||||
|
import io.element.android.x.textcomposer.MessageComposerMode
|
||||||
|
|
||||||
@Stable
|
@Immutable
|
||||||
data class MessageComposerViewState(
|
data class MessageComposerState(
|
||||||
// val roomId: String,
|
// val roomId: String,
|
||||||
// val canSendMessage: CanSendStatus = CanSendStatus.Allowed,
|
// val canSendMessage: CanSendStatus = CanSendStatus.Allowed,
|
||||||
val isSendButtonVisible: Boolean = false,
|
val isSendButtonVisible: Boolean = false,
|
||||||
|
|
@ -32,4 +32,6 @@ data class MessageComposerViewState(
|
||||||
// val voiceBroadcastState: VoiceBroadcastState? = null,
|
// val voiceBroadcastState: VoiceBroadcastState? = null,
|
||||||
val text: StableCharSequence? = null,
|
val text: StableCharSequence? = null,
|
||||||
val isFullScreen: Boolean = false,
|
val isFullScreen: Boolean = false,
|
||||||
) : MavericksState
|
val mode: MessageComposerMode = MessageComposerMode.Normal(""),
|
||||||
|
val eventSink: (MessageComposerEvents) -> Unit = {}
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.x.features.messages.textcomposer
|
|
||||||
|
|
||||||
import com.airbnb.mvrx.MavericksViewModel
|
|
||||||
import com.airbnb.mvrx.MavericksViewModelFactory
|
|
||||||
import dagger.assisted.Assisted
|
|
||||||
import dagger.assisted.AssistedInject
|
|
||||||
import io.element.android.x.anvilannotations.ContributesViewModel
|
|
||||||
import io.element.android.x.architecture.viewmodel.daggerMavericksViewModelFactory
|
|
||||||
import io.element.android.x.core.data.StableCharSequence
|
|
||||||
import io.element.android.x.di.SessionScope
|
|
||||||
import io.element.android.x.matrix.MatrixClient
|
|
||||||
|
|
||||||
@ContributesViewModel(SessionScope::class)
|
|
||||||
class MessageComposerViewModel @AssistedInject constructor(
|
|
||||||
private val client: MatrixClient,
|
|
||||||
@Assisted private val initialState: MessageComposerViewState
|
|
||||||
) : MavericksViewModel<MessageComposerViewState>(initialState) {
|
|
||||||
|
|
||||||
companion object :
|
|
||||||
MavericksViewModelFactory<MessageComposerViewModel, MessageComposerViewState> by daggerMavericksViewModelFactory()
|
|
||||||
|
|
||||||
fun onComposerFullScreenChange() {
|
|
||||||
setState {
|
|
||||||
copy(
|
|
||||||
isFullScreen = !isFullScreen
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateText(newText: CharSequence) {
|
|
||||||
setState {
|
|
||||||
copy(
|
|
||||||
text = StableCharSequence(newText),
|
|
||||||
isSendButtonVisible = newText.isNotEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
package io.element.android.x.features.messages.timeline
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import io.element.android.x.architecture.Async
|
||||||
|
import io.element.android.x.architecture.Presenter
|
||||||
|
import io.element.android.x.features.messages.MessageTimelineItemStateFactory
|
||||||
|
import io.element.android.x.matrix.MatrixClient
|
||||||
|
import io.element.android.x.matrix.core.EventId
|
||||||
|
import io.element.android.x.matrix.room.MatrixRoom
|
||||||
|
import io.element.android.x.matrix.timeline.MatrixTimeline
|
||||||
|
import io.element.android.x.matrix.timeline.MatrixTimelineItem
|
||||||
|
import io.element.android.x.matrix.ui.MatrixItemHelper
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val PAGINATION_COUNT = 50
|
||||||
|
|
||||||
|
class TimelinePresenter @Inject constructor(
|
||||||
|
private val appCoroutineScope: CoroutineScope,
|
||||||
|
private val client: MatrixClient,
|
||||||
|
private val room: MatrixRoom
|
||||||
|
) : Presenter<TimelineState> {
|
||||||
|
|
||||||
|
private val timeline = room.timeline()
|
||||||
|
private val matrixItemHelper = MatrixItemHelper(client)
|
||||||
|
private val messageTimelineItemStateFactory =
|
||||||
|
MessageTimelineItemStateFactory(matrixItemHelper, room, Dispatchers.Default)
|
||||||
|
|
||||||
|
private class TimelineCallback(private val coroutineScope: CoroutineScope, private val messageTimelineItemStateFactory: MessageTimelineItemStateFactory) : MatrixTimeline.Callback {
|
||||||
|
override fun onPushedTimelineItem(timelineItem: MatrixTimelineItem) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
messageTimelineItemStateFactory.pushItem(timelineItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): TimelineState {
|
||||||
|
|
||||||
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
val hasMoreToLoad = rememberSaveable {
|
||||||
|
mutableStateOf(timeline.hasMoreToLoad)
|
||||||
|
}
|
||||||
|
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
|
||||||
|
mutableStateOf(null)
|
||||||
|
}
|
||||||
|
val timelineItems = messageTimelineItemStateFactory
|
||||||
|
.flow()
|
||||||
|
.collectAsState(emptyList())
|
||||||
|
|
||||||
|
fun handleEvents(event: TimelineEvents) {
|
||||||
|
when (event) {
|
||||||
|
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(hasMoreToLoad)
|
||||||
|
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
timeline
|
||||||
|
.timelineItems()
|
||||||
|
.onEach(messageTimelineItemStateFactory::replaceWith)
|
||||||
|
.launchIn(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
timeline.callback = TimelineCallback(localCoroutineScope, messageTimelineItemStateFactory)
|
||||||
|
timeline.initialize()
|
||||||
|
onDispose {
|
||||||
|
timeline.callback = null
|
||||||
|
timeline.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineState(
|
||||||
|
highlightedEventId = highlightedEventId.value,
|
||||||
|
timelineItems = Async.Success(timelineItems.value),
|
||||||
|
hasMoreToLoad = hasMoreToLoad.value,
|
||||||
|
eventSink = ::handleEvents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun CoroutineScope.loadMore(hasMoreToLoad: MutableState<Boolean>) = launch {
|
||||||
|
timeline.paginateBackwards(PAGINATION_COUNT)
|
||||||
|
hasMoreToLoad.value = timeline.hasMoreToLoad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.x.features.messages.timeline
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import io.element.android.x.architecture.Async
|
||||||
|
import io.element.android.x.features.messages.model.MessagesTimelineItemState
|
||||||
|
import io.element.android.x.matrix.core.EventId
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class TimelineState(
|
||||||
|
val timelineItems: Async<List<MessagesTimelineItemState>> = Async.Uninitialized,
|
||||||
|
val hasMoreToLoad: Boolean = true,
|
||||||
|
val highlightedEventId: EventId? = null,
|
||||||
|
val eventSink: (TimelineEvents) -> Unit = {}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,412 @@
|
||||||
|
package io.element.android.x.features.messages.timeline
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.LastBaseline
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import io.element.android.x.architecture.Async
|
||||||
|
import io.element.android.x.core.compose.PairCombinedPreviewParameter
|
||||||
|
import io.element.android.x.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.x.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.x.features.messages.model.AggregatedReaction
|
||||||
|
import io.element.android.x.features.messages.model.MessagesItemGroupPosition
|
||||||
|
import io.element.android.x.features.messages.model.MessagesItemGroupPositionProvider
|
||||||
|
import io.element.android.x.features.messages.model.MessagesItemReactionState
|
||||||
|
import io.element.android.x.features.messages.model.MessagesTimelineItemState
|
||||||
|
import io.element.android.x.features.messages.model.content.MessagesTimelineItemContent
|
||||||
|
import io.element.android.x.features.messages.model.content.MessagesTimelineItemContentProvider
|
||||||
|
import io.element.android.x.features.messages.model.content.MessagesTimelineItemEncryptedContent
|
||||||
|
import io.element.android.x.features.messages.model.content.MessagesTimelineItemImageContent
|
||||||
|
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
|
||||||
|
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
|
||||||
|
import io.element.android.x.features.messages.model.content.MessagesTimelineItemUnknownContent
|
||||||
|
import io.element.android.x.features.messages.timeline.components.MessageEventBubble
|
||||||
|
import io.element.android.x.features.messages.timeline.components.MessagesReactionsView
|
||||||
|
import io.element.android.x.features.messages.timeline.components.MessagesTimelineItemEncryptedView
|
||||||
|
import io.element.android.x.features.messages.timeline.components.MessagesTimelineItemImageView
|
||||||
|
import io.element.android.x.features.messages.timeline.components.MessagesTimelineItemRedactedView
|
||||||
|
import io.element.android.x.features.messages.timeline.components.MessagesTimelineItemTextView
|
||||||
|
import io.element.android.x.features.messages.timeline.components.MessagesTimelineItemUnknownView
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TimelineView(
|
||||||
|
state: TimelineState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onMessageClicked: (MessagesTimelineItemState.MessageEvent) -> Unit = {},
|
||||||
|
onMessageLongClicked: (MessagesTimelineItemState.MessageEvent) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val lazyListState = rememberLazyListState()
|
||||||
|
val timelineItems = state.timelineItems.dataOrNull().orEmpty().toImmutableList()
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = lazyListState,
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
verticalArrangement = Arrangement.Bottom,
|
||||||
|
reverseLayout = true
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = timelineItems,
|
||||||
|
contentType = { timelineItem -> timelineItem.contentType() },
|
||||||
|
key = { timelineItem -> timelineItem.key() },
|
||||||
|
) { timelineItem ->
|
||||||
|
TimelineItemRow(
|
||||||
|
timelineItem = timelineItem,
|
||||||
|
isHighlighted = timelineItem.key() == state.highlightedEventId?.value,
|
||||||
|
onClick = onMessageClicked,
|
||||||
|
onLongClick = onMessageLongClicked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.hasMoreToLoad) {
|
||||||
|
item {
|
||||||
|
TimelineLoadingMoreIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onReachedLoadMore() {
|
||||||
|
state.eventSink(TimelineEvents.LoadMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
TimelineScrollHelper(
|
||||||
|
lazyListState = lazyListState,
|
||||||
|
timelineItems = timelineItems,
|
||||||
|
onLoadMore = ::onReachedLoadMore
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MessagesTimelineItemState.key(): String {
|
||||||
|
return when (this) {
|
||||||
|
is MessagesTimelineItemState.MessageEvent -> id
|
||||||
|
is MessagesTimelineItemState.Virtual -> id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MessagesTimelineItemState.contentType(): Int {
|
||||||
|
return when (this) {
|
||||||
|
is MessagesTimelineItemState.MessageEvent -> 0
|
||||||
|
is MessagesTimelineItemState.Virtual -> 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TimelineItemRow(
|
||||||
|
timelineItem: MessagesTimelineItemState,
|
||||||
|
isHighlighted: Boolean,
|
||||||
|
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
||||||
|
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
when (timelineItem) {
|
||||||
|
is MessagesTimelineItemState.Virtual -> return
|
||||||
|
is MessagesTimelineItemState.MessageEvent -> MessageEventRow(
|
||||||
|
messageEvent = timelineItem,
|
||||||
|
isHighlighted = isHighlighted,
|
||||||
|
onClick = { onClick(timelineItem) },
|
||||||
|
onLongClick = { onLongClick(timelineItem) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageEventRow(
|
||||||
|
messageEvent: MessagesTimelineItemState.MessageEvent,
|
||||||
|
isHighlighted: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val (parentAlignment, contentAlignment) = if (messageEvent.isMine) {
|
||||||
|
Pair(Alignment.CenterEnd, Alignment.End)
|
||||||
|
} else {
|
||||||
|
Pair(Alignment.CenterStart, Alignment.Start)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight(),
|
||||||
|
contentAlignment = parentAlignment
|
||||||
|
) {
|
||||||
|
Row {
|
||||||
|
if (!messageEvent.isMine) {
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = contentAlignment) {
|
||||||
|
if (messageEvent.showSenderInformation) {
|
||||||
|
MessageSenderInformation(
|
||||||
|
messageEvent.safeSenderName,
|
||||||
|
messageEvent.senderAvatar,
|
||||||
|
Modifier.zIndex(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MessageEventBubble(
|
||||||
|
groupPosition = messageEvent.groupPosition,
|
||||||
|
isMine = messageEvent.isMine,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
isHighlighted = isHighlighted,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.zIndex(-1f)
|
||||||
|
.widthIn(max = 320.dp)
|
||||||
|
) {
|
||||||
|
val contentModifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
when (messageEvent.content) {
|
||||||
|
is MessagesTimelineItemEncryptedContent -> MessagesTimelineItemEncryptedView(
|
||||||
|
content = messageEvent.content,
|
||||||
|
modifier = contentModifier
|
||||||
|
)
|
||||||
|
is MessagesTimelineItemRedactedContent -> MessagesTimelineItemRedactedView(
|
||||||
|
content = messageEvent.content,
|
||||||
|
modifier = contentModifier
|
||||||
|
)
|
||||||
|
is MessagesTimelineItemTextBasedContent -> MessagesTimelineItemTextView(
|
||||||
|
content = messageEvent.content,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier = contentModifier,
|
||||||
|
onTextClicked = onClick,
|
||||||
|
onTextLongClicked = onLongClick
|
||||||
|
)
|
||||||
|
is MessagesTimelineItemUnknownContent -> MessagesTimelineItemUnknownView(
|
||||||
|
content = messageEvent.content,
|
||||||
|
modifier = contentModifier
|
||||||
|
)
|
||||||
|
is MessagesTimelineItemImageContent -> MessagesTimelineItemImageView(
|
||||||
|
content = messageEvent.content,
|
||||||
|
modifier = contentModifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessagesReactionsView(
|
||||||
|
reactionsState = messageEvent.reactionsState,
|
||||||
|
modifier = Modifier
|
||||||
|
.zIndex(1f)
|
||||||
|
.offset(x = if (messageEvent.isMine) 0.dp else 20.dp, y = -(16.dp))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (messageEvent.isMine) {
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (messageEvent.groupPosition.isNew()) {
|
||||||
|
Spacer(modifier = modifier.height(8.dp))
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = modifier.height(2.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageSenderInformation(
|
||||||
|
sender: String,
|
||||||
|
senderAvatar: AvatarData?,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(modifier = modifier) {
|
||||||
|
if (senderAvatar != null) {
|
||||||
|
Avatar(senderAvatar)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = sender,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.alignBy(LastBaseline)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun BoxScope.TimelineScrollHelper(
|
||||||
|
lazyListState: LazyListState,
|
||||||
|
timelineItems: ImmutableList<MessagesTimelineItemState>,
|
||||||
|
onLoadMore: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
|
||||||
|
|
||||||
|
// Auto-scroll when new timeline items appear
|
||||||
|
LaunchedEffect(timelineItems, firstVisibleItemIndex) {
|
||||||
|
if (!lazyListState.isScrollInProgress &&
|
||||||
|
firstVisibleItemIndex < 2
|
||||||
|
) coroutineScope.launch {
|
||||||
|
lazyListState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle load more preloading
|
||||||
|
val loadMore by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val layoutInfo = lazyListState.layoutInfo
|
||||||
|
val totalItemsNumber = layoutInfo.totalItemsCount
|
||||||
|
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||||
|
lastVisibleItemIndex > (totalItemsNumber - 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(loadMore) {
|
||||||
|
snapshotFlow { loadMore }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
onLoadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump to bottom button
|
||||||
|
if (firstVisibleItemIndex > 2) {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
if (firstVisibleItemIndex > 10) {
|
||||||
|
lazyListState.scrollToItem(0)
|
||||||
|
} else {
|
||||||
|
lazyListState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = CircleShape,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.size(40.dp),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ArrowDownward, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TimelineLoadingMoreIndicator() {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight()
|
||||||
|
.padding(8.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessagesItemGroupPositionToMessagesTimelineItemContentProvider :
|
||||||
|
PairCombinedPreviewParameter<MessagesItemGroupPosition, MessagesTimelineItemContent>(
|
||||||
|
MessagesItemGroupPositionProvider() to MessagesTimelineItemContentProvider()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Suppress("PreviewPublic")
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun TimelineItemsPreview(
|
||||||
|
@PreviewParameter(MessagesTimelineItemContentProvider::class)
|
||||||
|
content: MessagesTimelineItemContent
|
||||||
|
) {
|
||||||
|
val timelineItems = persistentListOf(
|
||||||
|
// 3 items (First Middle Last) with isMine = false
|
||||||
|
createMessageEvent(
|
||||||
|
isMine = false,
|
||||||
|
content = content,
|
||||||
|
groupPosition = MessagesItemGroupPosition.First
|
||||||
|
),
|
||||||
|
createMessageEvent(
|
||||||
|
isMine = false,
|
||||||
|
content = content,
|
||||||
|
groupPosition = MessagesItemGroupPosition.Middle
|
||||||
|
),
|
||||||
|
createMessageEvent(
|
||||||
|
isMine = false,
|
||||||
|
content = content,
|
||||||
|
groupPosition = MessagesItemGroupPosition.Last
|
||||||
|
),
|
||||||
|
// 3 items (First Middle Last) with isMine = true
|
||||||
|
createMessageEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = content,
|
||||||
|
groupPosition = MessagesItemGroupPosition.First
|
||||||
|
),
|
||||||
|
createMessageEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = content,
|
||||||
|
groupPosition = MessagesItemGroupPosition.Middle
|
||||||
|
),
|
||||||
|
createMessageEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = content,
|
||||||
|
groupPosition = MessagesItemGroupPosition.Last
|
||||||
|
),
|
||||||
|
)
|
||||||
|
TimelineView(
|
||||||
|
state = TimelineState(
|
||||||
|
timelineItems = Async.Success(timelineItems)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMessageEvent(
|
||||||
|
isMine: Boolean,
|
||||||
|
content: MessagesTimelineItemContent,
|
||||||
|
groupPosition: MessagesItemGroupPosition
|
||||||
|
): MessagesTimelineItemState {
|
||||||
|
return MessagesTimelineItemState.MessageEvent(
|
||||||
|
id = Math.random().toString(),
|
||||||
|
senderId = "senderId",
|
||||||
|
senderAvatar = AvatarData("sender"),
|
||||||
|
content = content,
|
||||||
|
reactionsState = MessagesItemReactionState(
|
||||||
|
listOf(
|
||||||
|
AggregatedReaction("👍", "1")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
isMine = isMine,
|
||||||
|
senderDisplayName = "sender",
|
||||||
|
groupPosition = groupPosition,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.BorderStroke
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.Icons
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
@file:OptIn(ExperimentalFoundationApi::class)
|
@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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|
@ -14,13 +14,14 @@
|
||||||
* limitations under the License.
|
* 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.Icons
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
|
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
|
||||||
|
import io.element.android.x.features.messages.timeline.components.MessagesTimelineItemInformativeView
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesTimelineItemRedactedView(
|
fun MessagesTimelineItemRedactedView(
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.SpannableString
|
||||||
import android.text.style.URLSpan
|
import android.text.style.URLSpan
|
||||||
|
|
@ -30,7 +30,7 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.core.text.util.LinkifyCompat
|
import androidx.core.text.util.LinkifyCompat
|
||||||
import io.element.android.x.designsystem.LinkColor
|
import io.element.android.x.designsystem.LinkColor
|
||||||
import io.element.android.x.designsystem.components.ClickableLinkText
|
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
|
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.Icons
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
|
@ -14,12 +14,6 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.x.features.messages.model
|
package io.element.android.x.di
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
abstract class RoomScope private constructor()
|
||||||
|
|
||||||
@Stable
|
|
||||||
data class MessagesItemActionsSheetState(
|
|
||||||
val targetItem: MessagesTimelineItemState.MessageEvent,
|
|
||||||
val actions: List<MessagesItemAction>
|
|
||||||
)
|
|
||||||
|
|
@ -18,7 +18,6 @@ package io.element.android.x.matrix.timeline
|
||||||
|
|
||||||
import io.element.android.x.core.coroutine.CoroutineDispatchers
|
import io.element.android.x.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.x.matrix.room.MatrixRoom
|
import io.element.android.x.matrix.room.MatrixRoom
|
||||||
import java.util.Collections
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.Flow
|
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.TimelineDiff
|
||||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
import org.matrix.rustcomponents.sdk.TimelineListener
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
class MatrixTimeline(
|
class MatrixTimeline(
|
||||||
private val matrixRoom: MatrixRoom,
|
private val matrixRoom: MatrixRoom,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-compose-library")
|
id("io.element.android-compose-library")
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,25 @@
|
||||||
|
|
||||||
package io.element.android.x.textcomposer
|
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
|
data class Normal(val content: CharSequence?) : MessageComposerMode
|
||||||
|
|
||||||
sealed class Special(open val eventId: String, open val defaultContent: CharSequence) :
|
sealed class Special(open val eventId: String, open val defaultContent: CharSequence) :
|
||||||
MessageComposerMode
|
MessageComposerMode
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
data class Edit(override val eventId: String, override val defaultContent: CharSequence) :
|
data class Edit(override val eventId: String, override val defaultContent: CharSequence) :
|
||||||
Special(eventId, defaultContent)
|
Special(eventId, defaultContent)
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
class Quote(override val eventId: String, override val defaultContent: CharSequence) :
|
class Quote(override val eventId: String, override val defaultContent: CharSequence) :
|
||||||
Special(eventId, defaultContent)
|
Special(eventId, defaultContent)
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
class Reply(
|
class Reply(
|
||||||
val senderName: String,
|
val senderName: String,
|
||||||
override val eventId: String,
|
override val eventId: String,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue