[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:
jonnyandrew
2023-09-29 10:11:18 +01:00
committed by GitHub
parent 1a87bf6352
commit 70dfbd7c31
36 changed files with 926 additions and 489 deletions

1
changelog.d/1447.feature Normal file
View File

@@ -0,0 +1 @@
[Rich text editor] Add full screen mode

View File

@@ -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()
}

View File

@@ -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"),
),

View File

@@ -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,
)
}
}

View File

@@ -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(

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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,
)
}
}

View File

@@ -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(

View File

@@ -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"

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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)
}