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