[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 fa82639c4f
commit 53cf82f025
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
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)
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:365e3b0f92dd56b4404f50861d119fa58435e9d9c887142632f5566e0dc4c9f1
size 10796
oid sha256:b91884f47f7789ccb1e22b29be6cfcfa6fa5975614145eedd4d884b867cfcf0f
size 18458

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d7213c01580a7c0caa8d63a8540c9f69208ac8d906a70fd6399c4fa5cacb606e
size 10093
oid sha256:0c0892fa19589139b4d47a514f0a56486ab47f82b94ef9426f8bb623be7cc0cb
size 16786

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f78a54b9592de1a24bac3796f725e781595c32330f3d340d96c196c0e732bb9d
size 53831
oid sha256:88bdef3999877e5017bfe0e0ead1514e4e6a58abcde0b0167d4b0ad9d4abd1e0
size 54020

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e16e72290141082417b835a836c81f0a8b127412a04e8cb2c2a36ea8c922c2e6
size 55147
oid sha256:35f420b550029d7f8b22d73ea0349d2794cc2e5c5f3080799f496774fad7d2ff
size 55440

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4b010513e52a549f498f1458db6ebdda6ecf4ad3f886a570a47142cbd8704f90
size 55907
oid sha256:3f62a8a4eded0b742e911970837fe1003228834dd07a216a9bdc4acef37aa468
size 55800

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:20eee7798706209e11f5d59ab816b7a0589481fb7c0059d2559c848cc992e8ef
size 51481
oid sha256:1314aaf5394d03b5d08eccdb29783ce8d949f44d3eea28ec8ce434b830515304
size 51662

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ecdc26ae1b8943734a8ec1020d7ca9ad4a8e570f5295eb568f80ed314585a9a9
size 51981

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:54352c8066ebb4ac6c0079373cc967abcf9c99f04ddcfce0161fff3bfc34ad6c
size 52258
oid sha256:32ae9c61f8a01bd54b9fb51af5f0dff222f4df0be1003a9c6ad680d20877448b
size 52275

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:690ffd45d28f6056b835f21c495ff6d959e089d376e1afcb10395d2e8dd487f3
size 53490
oid sha256:1785f0fe49a5afd9b152f6ddb51acad7ba1700b2711cf302ef3a490d948fcd96
size 53618

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:84f147d29e31f1d99519e282d69c0dfa24363fc167d2c6eda323590a70d63677
size 51222
oid sha256:455b414e5da5d5174a8d87ef37219c9754e3558b39f025d7aab226b041c51096
size 51305

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ab95b8dd9c480e304333f67032ef42c7a66bc717572a328dbf2f9551773552f7
size 49850
oid sha256:a0b83b1d37b34cdf0769e83f625d66479638ce402d5c8e76ef40548414d34400
size 49862

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b09d4a60c3d9944cd6d50a8f0a06d5b3522eba7884f3b5e1e6889f93e8dd1794
size 50026

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3c5bd25caa8c462fd9a98fef14f011450adb414ec14d1a8b285d37692185442c
size 5616

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:84d00d9738ffbd2b3798e3c31b13d4a086377abaf250049aca24fdfef1132ed8
size 5459

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c3a8bb9cbb98e7acf4d5e6aee29071ce851229622ada00723336f1167430f123
size 13730
oid sha256:a0c5c53f53eb3cdda73391fabda2658b5c009e9a13761ef9acae37530f3cb1b5
size 14017

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:38e673e6cb326662096558fc9f25b5b84835004217ae06d2be75e9d6232e0ddf
size 12893
oid sha256:e5b786b07d92459399099e0f7730804de2f2123d8c5eb51ca64f7609768ecc34
size 13157

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:463156fde1b2766a5c17f0e4aaac79243496d832ac115d090ec212e0077c4b4a
size 41560
oid sha256:54f6712d9cbd60c6a6d108ddcff08492a5e74cbba7d1f4b5f3312a068e4dd798
size 43207

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f490d44a0609fac5f4ae08ec20472cf55ca90bbd7b4c719087b47be4d4e9ab14
size 39270
oid sha256:e156ed6831abb52d8bbd95934517f423d0cae06e21e9266b099174aad55228ab
size 40662

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:16009c30863c10bda995eb570637d754075703d2bcce95b366e34667a5d53671
size 84173
oid sha256:fe441727d1a64a7439a15780a208b50bfacd875d6a6fa9bee980508e9beda5a7
size 87262

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:179ee5b9d4d8df4a54c09e7406ec006b3bc7407ce27daa2d69369ca89cee373d
size 80573
oid sha256:031e8632fb034425c6e30801abb5f57271e5411784501ee27cd413db363e2d67
size 83036

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da51cd385e731b4544488be10e9a71f79714539d7cbcaaa0b760f3e2b3cf9272
size 45199
oid sha256:fd38c36b9b85c3ca3e290e2c0fd338ca52552398d2497a92a573ffd3f133b567
size 47937

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1624403c6bbd12925b84551215c1ea3a2d3073375d3422ee1ffd5578a3635659
size 42351
oid sha256:033292295440c19363ebf1cc425c60226cf6f811a25219ae7939c8d2871091c4
size 45020