[Rich text editor] Add full screen mode (#1447)
- Add full screen mode for the rich text editor (RTE). When text formatting options are enabled, the editor can be dragged to full screen. - Remove `ConstraintLayout` from `textcomposer` module, now made much simpler now the RTE supports being called in multiple layouts matrix-org/matrix-rich-text-editor#822 - Part of vector-im/element-meta#1973 - Includes design from #1315 - Fixes #1293 (through new layout) - Fixes #1394 (through inclusion of matrix-org/matrix-rich-text-editor#824) - Fixes #1259 (through inclusion of matrix-org/matrix-rich-text-editor#820) --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
1
changelog.d/1447.feature
Normal file
1
changelog.d/1447.feature
Normal file
@@ -0,0 +1 @@
|
||||
[Rich text editor] Add full screen mode
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* 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)
|
||||
|
||||
package io.element.android.features.messages.impl
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.material3.BottomSheetScaffold
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A [BottomSheetScaffold] that allows the sheet to be expanded the screen height
|
||||
* of the sheet contents.
|
||||
*
|
||||
* @param content The main content.
|
||||
* @param sheetContent The sheet content.
|
||||
* @param sheetDragHandle The drag handle for the sheet.
|
||||
* @param sheetSwipeEnabled Whether the sheet can be swiped. This value is ignored and swipe is disabled if the sheet content overflows.
|
||||
* @param sheetShape The shape of the sheet.
|
||||
* @param sheetTonalElevation The tonal elevation of the sheet.
|
||||
* @param sheetShadowElevation The shadow elevation of the sheet.
|
||||
* @param modifier The modifier for the layout.
|
||||
* @param sheetContentKey The key for the sheet content. If the key changes, the sheet will be remeasured.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ExpandableBottomSheetScaffold(
|
||||
content: @Composable (padding: PaddingValues) -> Unit,
|
||||
sheetContent: @Composable (subcomposing: Boolean) -> Unit,
|
||||
sheetDragHandle: @Composable () -> Unit,
|
||||
sheetSwipeEnabled: Boolean,
|
||||
sheetShape: Shape,
|
||||
sheetTonalElevation: Dp,
|
||||
sheetShadowElevation: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
sheetContentKey: Int? = null,
|
||||
) {
|
||||
val scaffoldState = rememberBottomSheetScaffoldState(
|
||||
bottomSheetState = rememberStandardBottomSheetState(
|
||||
initialValue = SheetValue.PartiallyExpanded,
|
||||
skipHiddenState = true,
|
||||
)
|
||||
)
|
||||
|
||||
// If the content overflows, we disable swipe to prevent the sheet from intercepting
|
||||
// scroll events of the sheet content.
|
||||
var contentOverflows by remember { mutableStateOf(false) }
|
||||
val sheetSwipeEnabledIfPossible by remember(contentOverflows, sheetSwipeEnabled) {
|
||||
derivedStateOf {
|
||||
sheetSwipeEnabled && !contentOverflows
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(sheetSwipeEnabledIfPossible) {
|
||||
if (!sheetSwipeEnabledIfPossible) {
|
||||
scaffoldState.bottomSheetState.partialExpand()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Scaffold(
|
||||
sheetContent: @Composable () -> Unit,
|
||||
dragHandle: @Composable () -> Unit,
|
||||
peekHeight: Dp,
|
||||
) {
|
||||
BottomSheetScaffold(
|
||||
modifier = Modifier,
|
||||
scaffoldState = scaffoldState,
|
||||
sheetPeekHeight = peekHeight,
|
||||
sheetSwipeEnabled = sheetSwipeEnabledIfPossible,
|
||||
sheetDragHandle = dragHandle,
|
||||
sheetShape = sheetShape,
|
||||
content = content,
|
||||
sheetContent = { sheetContent() },
|
||||
sheetTonalElevation = sheetTonalElevation,
|
||||
sheetShadowElevation = sheetShadowElevation,
|
||||
)
|
||||
}
|
||||
|
||||
SubcomposeLayout(
|
||||
modifier = modifier,
|
||||
measurePolicy = { constraints: Constraints ->
|
||||
val sheetContentSub = subcompose(Slot.SheetContent(sheetContentKey)) { sheetContent(true) }.map {
|
||||
it.measure(Constraints(maxWidth = constraints.maxWidth))
|
||||
}.first()
|
||||
val dragHandleSub = subcompose(Slot.DragHandle) { sheetDragHandle() }.map {
|
||||
it.measure(Constraints(maxWidth = constraints.maxWidth))
|
||||
}.firstOrNull()
|
||||
val dragHandleHeight = dragHandleSub?.height?.toDp() ?: 0.dp
|
||||
|
||||
val maxHeight = constraints.maxHeight.toDp()
|
||||
val contentHeight = sheetContentSub.height.toDp() + dragHandleHeight
|
||||
|
||||
contentOverflows = contentHeight > maxHeight
|
||||
|
||||
val peekHeight = min(
|
||||
maxHeight, // prevent the sheet from expanding beyond the screen
|
||||
contentHeight
|
||||
)
|
||||
|
||||
val scaffoldPlaceables = subcompose(Slot.Scaffold) {
|
||||
Scaffold({
|
||||
Layout(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
measurePolicy = { measurables, constraints ->
|
||||
val constraintHeight = constraints.maxHeight
|
||||
val offset = scaffoldState.bottomSheetState.getOffset() ?: 0
|
||||
val height = Integer.max(0, constraintHeight - offset)
|
||||
val top = measurables[0].measure(
|
||||
constraints.copy(
|
||||
minHeight = height,
|
||||
maxHeight = height
|
||||
)
|
||||
)
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
top.place(x = 0, y = 0)
|
||||
}
|
||||
},
|
||||
content = { sheetContent(false) })
|
||||
}, sheetDragHandle, peekHeight)
|
||||
}.map { measurable: Measurable ->
|
||||
measurable.measure(constraints)
|
||||
}
|
||||
val scaffoldPlaceable = scaffoldPlaceables.first()
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
scaffoldPlaceable.place(0, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun SheetState.getOffset(): Int? = try {
|
||||
requireOffset().roundToInt()
|
||||
} catch (e: IllegalStateException) {
|
||||
null
|
||||
}
|
||||
|
||||
private sealed class Slot {
|
||||
data class SheetContent(val key: Int?) : Slot()
|
||||
data object DragHandle : Slot()
|
||||
data object Scaffold : Slot()
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||
roomName = Async.Uninitialized,
|
||||
roomAvatar = Async.Uninitialized,
|
||||
),
|
||||
aMessagesState().copy(composerState = aMessageComposerState().copy(showTextFormatting = true)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,9 +56,7 @@ fun aMessagesState() = MessagesState(
|
||||
userHasPermissionToSendMessage = true,
|
||||
userHasPermissionToRedact = false,
|
||||
composerState = aMessageComposerState().copy(
|
||||
richTextEditorState = RichTextEditorState("Hello", fake = true).apply {
|
||||
requestFocus()
|
||||
},
|
||||
richTextEditorState = RichTextEditorState("Hello", initialFocus = true),
|
||||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal("Hello"),
|
||||
),
|
||||
|
||||
@@ -19,8 +19,8 @@ package io.element.android.features.messages.impl
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
@@ -32,13 +32,13 @@ 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.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
@@ -71,8 +71,9 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
@@ -86,7 +87,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import timber.log.Timber
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessagesView(
|
||||
state: MessagesState,
|
||||
@@ -277,40 +277,53 @@ private fun MessagesViewContent(
|
||||
modifier: Modifier = Modifier,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
.imePadding(),
|
||||
) {
|
||||
// Hide timeline if composer is full screen
|
||||
if (!state.composerState.isFullScreen) {
|
||||
TimelineView(
|
||||
state = state.timelineState,
|
||||
modifier = Modifier.weight(1f),
|
||||
onMessageClicked = onMessageClicked,
|
||||
onMessageLongClicked = onMessageLongClicked,
|
||||
onUserDataClicked = onUserDataClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClicked = onReactionClicked,
|
||||
onReactionLongClicked = onReactionLongClicked,
|
||||
onMoreReactionsClicked = onMoreReactionsClicked,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
)
|
||||
}
|
||||
if (state.userHasPermissionToSendMessage) {
|
||||
MessageComposerView(
|
||||
state = state.composerState,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
onCreatePollClicked = onCreatePollClicked,
|
||||
enableTextFormatting = state.enableTextFormatting,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(Alignment.Bottom)
|
||||
)
|
||||
} else {
|
||||
CantSendMessageBanner()
|
||||
}
|
||||
ExpandableBottomSheetScaffold(
|
||||
sheetDragHandle = if (state.composerState.showTextFormatting) {
|
||||
@Composable { BottomSheetDragHandle() }
|
||||
} else {
|
||||
@Composable {}
|
||||
},
|
||||
sheetSwipeEnabled = state.composerState.showTextFormatting,
|
||||
sheetShape = if (state.composerState.showTextFormatting) MaterialTheme.shapes.large else RectangleShape,
|
||||
content = { paddingValues ->
|
||||
TimelineView(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
state = state.timelineState,
|
||||
onMessageClicked = onMessageClicked,
|
||||
onMessageLongClicked = onMessageLongClicked,
|
||||
onUserDataClicked = onUserDataClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClicked = onReactionClicked,
|
||||
onReactionLongClicked = onReactionLongClicked,
|
||||
onMoreReactionsClicked = onMoreReactionsClicked,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
)
|
||||
},
|
||||
sheetContent = { subcomposing: Boolean ->
|
||||
if (state.userHasPermissionToSendMessage) {
|
||||
MessageComposerView(
|
||||
state = state.composerState,
|
||||
subcomposing = subcomposing,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
onCreatePollClicked = onCreatePollClicked,
|
||||
enableTextFormatting = state.enableTextFormatting,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
CantSendMessageBanner()
|
||||
}
|
||||
},
|
||||
sheetContentKey = state.composerState.richTextEditorState.lineCount,
|
||||
sheetTonalElevation = 0.dp,
|
||||
sheetShadowElevation = 0.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +155,9 @@ class MessageComposerPresenter @Inject constructor(
|
||||
when (event) {
|
||||
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
||||
MessageComposerEvents.CloseSpecialMode -> {
|
||||
richTextEditorState.setHtml("")
|
||||
localCoroutineScope.launch {
|
||||
richTextEditorState.setHtml("")
|
||||
}
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal("")
|
||||
}
|
||||
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
@Stable
|
||||
data class MessageComposerState(
|
||||
val richTextEditorState: RichTextEditorState,
|
||||
val isFullScreen: Boolean,
|
||||
@@ -34,7 +35,6 @@ data class MessageComposerState(
|
||||
val attachmentsState: AttachmentsState,
|
||||
val eventSink: (MessageComposerEvents) -> Unit,
|
||||
) {
|
||||
val canSendMessage: Boolean = richTextEditorState.messageHtml.isNotEmpty()
|
||||
val hasFocus: Boolean = richTextEditorState.hasFocus
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
|
||||
}
|
||||
|
||||
fun aMessageComposerState(
|
||||
requestFocus: Boolean = true,
|
||||
composerState: RichTextEditorState = RichTextEditorState("", fake = true),
|
||||
composerState: RichTextEditorState = RichTextEditorState(""),
|
||||
isFullScreen: Boolean = false,
|
||||
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
|
||||
showTextFormatting: Boolean = false,
|
||||
@@ -38,7 +37,7 @@ fun aMessageComposerState(
|
||||
canCreatePoll: Boolean = true,
|
||||
attachmentsState: AttachmentsState = AttachmentsState.None,
|
||||
) = MessageComposerState(
|
||||
richTextEditorState = composerState.apply { if(requestFocus) requestFocus() },
|
||||
richTextEditorState = composerState,
|
||||
isFullScreen = isFullScreen,
|
||||
mode = mode,
|
||||
showTextFormatting = showTextFormatting,
|
||||
|
||||
@@ -17,26 +17,29 @@
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.textcomposer.Message
|
||||
import io.element.android.libraries.textcomposer.TextComposer
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun MessageComposerView(
|
||||
state: MessageComposerState,
|
||||
subcomposing: Boolean,
|
||||
onSendLocationClicked: () -> Unit,
|
||||
onCreatePollClicked: () -> Unit,
|
||||
enableTextFormatting: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onFullscreenToggle() {
|
||||
state.eventSink(MessageComposerEvents.ToggleFullScreenState)
|
||||
}
|
||||
|
||||
fun sendMessage(message: Message) {
|
||||
state.eventSink(MessageComposerEvents.SendMessage(message))
|
||||
}
|
||||
@@ -57,6 +60,13 @@ fun MessageComposerView(
|
||||
state.eventSink(MessageComposerEvents.Error(error))
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
fun onRequestFocus() {
|
||||
coroutineScope.launch {
|
||||
state.richTextEditorState.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AttachmentsBottomSheet(
|
||||
state = state,
|
||||
@@ -67,8 +77,8 @@ fun MessageComposerView(
|
||||
|
||||
TextComposer(
|
||||
state = state.richTextEditorState,
|
||||
canSendMessage = state.canSendMessage,
|
||||
onRequestFocus = { state.richTextEditorState.requestFocus() },
|
||||
subcomposing = subcomposing,
|
||||
onRequestFocus = ::onRequestFocus,
|
||||
onSendMessage = ::sendMessage,
|
||||
composerMode = state.mode,
|
||||
showTextFormatting = state.showTextFormatting,
|
||||
@@ -84,10 +94,22 @@ fun MessageComposerView(
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MessageComposerViewPreview(@PreviewParameter(MessageComposerStateProvider::class) state: MessageComposerState) = ElementPreview {
|
||||
MessageComposerView(
|
||||
state = state,
|
||||
onSendLocationClicked = {},
|
||||
onCreatePollClicked = {},
|
||||
enableTextFormatting = true,
|
||||
)
|
||||
Column {
|
||||
MessageComposerView(
|
||||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
state = state,
|
||||
onSendLocationClicked = {},
|
||||
onCreatePollClicked = {},
|
||||
enableTextFormatting = true,
|
||||
subcomposing = false,
|
||||
)
|
||||
MessageComposerView(
|
||||
modifier = Modifier.height(200.dp),
|
||||
state = state,
|
||||
onSendLocationClicked = {},
|
||||
onCreatePollClicked = {},
|
||||
enableTextFormatting = true,
|
||||
subcomposing = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
package io.element.android.features.messages.textcomposer
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.remember
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
@@ -102,7 +103,6 @@ class MessageComposerPresenterTest {
|
||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(initialState.canShareLocation).isTrue()
|
||||
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
|
||||
assertThat(initialState.canSendMessage).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,13 +132,9 @@ class MessageComposerPresenterTest {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.richTextEditorState.setHtml(A_MESSAGE)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.canSendMessage).isTrue()
|
||||
withMessageState.richTextEditorState.setHtml("")
|
||||
val withEmptyMessageState = awaitItem()
|
||||
assertThat(withEmptyMessageState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(withEmptyMessageState.canSendMessage).isFalse()
|
||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
initialState.richTextEditorState.setHtml("")
|
||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +142,8 @@ class MessageComposerPresenterTest {
|
||||
fun `present - change mode to edit`() = runTest {
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
val state = presenter.present()
|
||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
||||
}.test {
|
||||
skipItems(1)
|
||||
var state = awaitItem()
|
||||
@@ -156,7 +153,6 @@ class MessageComposerPresenterTest {
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
state = awaitItem()
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
assertThat(state.canSendMessage).isTrue()
|
||||
backToNormalMode(state, skipCount = 1)
|
||||
}
|
||||
}
|
||||
@@ -174,7 +170,6 @@ class MessageComposerPresenterTest {
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(state.canSendMessage).isFalse()
|
||||
backToNormalMode(state)
|
||||
}
|
||||
}
|
||||
@@ -192,7 +187,6 @@ class MessageComposerPresenterTest {
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(state.canSendMessage).isFalse()
|
||||
backToNormalMode(state)
|
||||
}
|
||||
}
|
||||
@@ -201,18 +195,17 @@ class MessageComposerPresenterTest {
|
||||
fun `present - send message`() = runTest {
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
val state = presenter.present()
|
||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.richTextEditorState.setHtml(A_MESSAGE)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.canSendMessage).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(messageSentState.canSendMessage).isFalse()
|
||||
waitForPredicate { analyticsService.capturedEvents.size == 1 }
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
@@ -233,7 +226,8 @@ class MessageComposerPresenterTest {
|
||||
fakeMatrixRoom,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
val state = presenter.present()
|
||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
@@ -244,7 +238,6 @@ class MessageComposerPresenterTest {
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.canSendMessage).isTrue()
|
||||
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
|
||||
val withEditedMessageState = awaitItem()
|
||||
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
|
||||
@@ -252,7 +245,6 @@ class MessageComposerPresenterTest {
|
||||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(messageSentState.canSendMessage).isFalse()
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
@@ -273,7 +265,8 @@ class MessageComposerPresenterTest {
|
||||
fakeMatrixRoom,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
val state = presenter.present()
|
||||
remember(state, state.richTextEditorState.messageHtml) { state }
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
@@ -284,7 +277,6 @@ class MessageComposerPresenterTest {
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.canSendMessage).isTrue()
|
||||
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
|
||||
val withEditedMessageState = awaitItem()
|
||||
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
|
||||
@@ -292,7 +284,6 @@ class MessageComposerPresenterTest {
|
||||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(messageSentState.canSendMessage).isFalse()
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
@@ -323,16 +314,11 @@ class MessageComposerPresenterTest {
|
||||
val state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(state.canSendMessage).isFalse()
|
||||
state.richTextEditorState.setHtml(A_REPLY)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
|
||||
assertThat(withMessageState.canSendMessage).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
|
||||
skipItems(1)
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
|
||||
state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(messageSentState.canSendMessage).isFalse()
|
||||
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY to A_REPLY)
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||
Composer(
|
||||
@@ -703,7 +689,6 @@ class MessageComposerPresenterTest {
|
||||
val normalState = awaitItem()
|
||||
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||
assertThat(normalState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(normalState.canSendMessage).isFalse()
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
|
||||
@@ -46,7 +46,7 @@ dependencyanalysis = "1.23.1"
|
||||
stem = "2.3.0"
|
||||
sqldelight = "1.5.5"
|
||||
telephoto = "0.6.2"
|
||||
wysiwyg = "2.12.0"
|
||||
wysiwyg = "2.14.0"
|
||||
|
||||
# DI
|
||||
dagger = "2.48"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* 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.libraries.designsystem.theme.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.requiredHeight
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
fun BottomSheetDragHandle(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(36.dp)
|
||||
.background(Color.Transparent)
|
||||
.fillMaxWidth()
|
||||
.clip(RectangleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.requiredHeight(72.dp)
|
||||
.offset(y = 18.dp)
|
||||
.clip(MaterialTheme.shapes.extraLarge)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.border(0.5.dp, ElementTheme.colors.borderDisabled, MaterialTheme.shapes.extraLarge)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(32.dp)
|
||||
.height(4.dp)
|
||||
.background(ElementTheme.colors.iconQuaternary, RoundedCornerShape(2.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun BottomSheetDragHandlePreview() = ElementPreview {
|
||||
BottomSheetDragHandle()
|
||||
}
|
||||
@@ -32,8 +32,6 @@ dependencies {
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.constraintlayout.compose)
|
||||
|
||||
implementation(libs.matrix.richtexteditor)
|
||||
api(libs.matrix.richtexteditor.compose)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* 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.libraries.textcomposer.components
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.text.applyScaleUp
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
|
||||
@Composable
|
||||
internal fun textInputRoundedCornerShape(
|
||||
composerMode: MessageComposerMode,
|
||||
): RoundedCornerShape {
|
||||
val roundCornerSmall = 20.dp.applyScaleUp()
|
||||
val roundCornerLarge = 21.dp.applyScaleUp()
|
||||
|
||||
val roundedCornerSize = if (composerMode is MessageComposerMode.Special) {
|
||||
roundCornerSmall
|
||||
} else {
|
||||
roundCornerLarge
|
||||
}
|
||||
|
||||
val roundedCornerSizeState = animateDpAsState(
|
||||
targetValue = roundedCornerSize,
|
||||
animationSpec = tween(
|
||||
durationMillis = 100,
|
||||
),
|
||||
label = "roundedCornerSizeAnimation"
|
||||
)
|
||||
return RoundedCornerShape(roundedCornerSizeState.value)
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user