[Message actions] New UI for message composer and editing (#526)

* Add UI for edit composer mode

* Remove leftover code from the RTE implementation

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2023-06-07 13:06:19 +02:00
committed by GitHub
parent 759abf1565
commit 595fbda220
29 changed files with 425 additions and 877 deletions

View File

@@ -5,8 +5,6 @@ appId: ${APP_ID}
- inputText: ${ROOM_NAME.substring(0, 3)}
- takeScreenshot: build/maestro/400-SearchRoom
- tapOn: ${ROOM_NAME}
# Close keyboard
- hideKeyboard
# Back from timeline
- back
# Close keyboard

View File

@@ -4,10 +4,8 @@ appId: ${APP_ID}
# TODO Create a room on a new account
- tapOn: ${ROOM_NAME}
- takeScreenshot: build/maestro/500-Timeline
- tapOn: "Message"
- tapOn: "Message"
- inputText: "Hello world!"
- tapOn: "Toggle full screen mode"
- tapOn: "Toggle full screen mode"
- tapOn: "Send"
- hideKeyboard
- back

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

@@ -0,0 +1 @@
New UI for composer and editing messages

View File

@@ -23,7 +23,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.textcomposer.TextComposer
@Composable
@@ -52,17 +51,14 @@ fun MessageComposerView(
TextComposer(
onSendMessage = ::sendMessage,
fullscreen = state.isFullScreen,
onFullscreenToggle = ::onFullscreenToggle,
composerMode = state.mode,
onCloseSpecialMode = ::onCloseSpecialMode,
onResetComposerMode = ::onCloseSpecialMode,
onComposerTextChange = ::onComposerTextChange,
onAddAttachment = {
state.eventSink(MessageComposerEvents.AddAttachment)
},
composerCanSendMessage = state.isSendButtonVisible,
composerText = state.text?.charSequence?.toString(),
isInDarkMode = !ElementTheme.colors.isLight,
modifier = modifier
)
}

View File

@@ -46,6 +46,7 @@ fun elementColorsDark() = ElementColors(
gray400 = Compound_Gray_400_Dark,
gray1400 = Compound_Gray_1400_Dark,
textActionCritical = TextColorCriticalDark,
accentColor = Color(0xFF0DBD8B),
placeholder = Compound_Gray_800_Dark,
isLight = false,
)

View File

@@ -46,6 +46,7 @@ fun elementColorsLight() = ElementColors(
gray400 = Compound_Gray_400_Light,
gray1400 = Compound_Gray_1400_Light,
textActionCritical = TextColorCriticalLight,
accentColor = Color(0xFF0DBD8B),
placeholder = Compound_Gray_800_Light,
isLight = true,
)

View File

@@ -33,6 +33,7 @@ class ElementColors(
gray400: Color,
gray1400: Color,
textActionCritical: Color,
accentColor: Color,
placeholder: Color,
isLight: Boolean
) {
@@ -61,6 +62,9 @@ class ElementColors(
var textActionCritical by mutableStateOf(textActionCritical)
private set
var accentColor by mutableStateOf(accentColor)
private set
var placeholder by mutableStateOf(placeholder)
private set
@@ -77,6 +81,7 @@ class ElementColors(
gray400: Color = this.gray400,
gray1400: Color = this.gray1400,
textActionCritical: Color = this.textActionCritical,
accentColor: Color = this.accentColor,
placeholder: Color = this.placeholder,
isLight: Boolean = this.isLight,
) = ElementColors(
@@ -89,6 +94,7 @@ class ElementColors(
gray400 = gray400,
gray1400 = gray1400,
textActionCritical = textActionCritical,
accentColor = accentColor,
placeholder = placeholder,
isLight = isLight,
)
@@ -103,6 +109,7 @@ class ElementColors(
gray400 = other.gray400
gray1400 = other.gray1400
textActionCritical = other.textActionCritical
accentColor = other.accentColor
placeholder = other.placeholder
isLight = other.isLight
}

View File

@@ -39,6 +39,7 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -95,6 +96,53 @@ fun OutlinedTextField(
)
}
@Composable
fun OutlinedTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = OutlinedTextFieldDefaults.shape,
colors: TextFieldColors = OutlinedTextFieldDefaults.colors()
) {
androidx.compose.material3.OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
enabled = enabled,
readOnly = readOnly,
textStyle = textStyle,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
supportingText = supportingText,
isError = isError,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
singleLine = singleLine,
maxLines = maxLines,
interactionSource = interactionSource,
shape = shape,
colors = colors,
)
}
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.onTabOrEnterKeyFocusNext(focusManager: FocusManager): Modifier = onPreviewKeyEvent { event ->
if (event.key == Key.Tab || event.key == Key.Enter) {

View File

@@ -40,6 +40,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -96,6 +97,53 @@ fun TextField(
)
}
@Composable
fun TextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors()
) {
androidx.compose.material3.TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
enabled = enabled,
readOnly = readOnly,
textStyle = textStyle,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
supportingText = supportingText,
isError = isError,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
singleLine = singleLine,
maxLines = maxLines,
interactionSource = interactionSource,
shape = shape,
colors = colors,
)
}
@Preview(group = PreviewGroup.TextFields)
@Composable
internal fun TextFieldLightPreview() =

View File

@@ -1,56 +0,0 @@
/*
* 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
import android.net.Uri
import android.text.Editable
import android.widget.EditText
import android.widget.ImageButton
// Imported from Element Android
interface MessageComposerView {
companion object {
const val MAX_LINES_WHEN_COLLAPSED = 10
}
val text: Editable?
val formattedText: String?
val editText: EditText
val emojiButton: ImageButton?
val sendButton: ImageButton
val attachmentButton: ImageButton
var callback: Callback?
fun setTextIfDifferent(text: CharSequence?): Boolean
fun renderComposerMode(mode: MessageComposerMode)
}
interface Callback {
// From ComposerEditText.Callback
fun onRichContentSelected(contentUri: Uri): Boolean
// From ComposerEditText.Callback
fun onTextChanged(text: CharSequence)
fun onCloseRelatedMessage()
fun onSendMessage(text: CharSequence)
fun onAddAttachment()
fun onExpandOrCompactChange()
fun onFullScreenModeChanged()
}

View File

@@ -1,552 +0,0 @@
/*
* 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
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
import android.text.Editable
import android.text.TextWatcher
import android.util.AttributeSet
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import com.google.android.material.shape.MaterialShapeDrawable
import io.element.android.libraries.androidutils.ui.DimensionConverter
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.androidutils.ui.showKeyboard
import io.element.android.libraries.textcomposer.databinding.ComposerRichTextLayoutBinding
import io.element.android.libraries.textcomposer.databinding.ViewRichTextMenuButtonBinding
import io.element.android.libraries.textcomposer.tools.setTextIfDifferent
import io.element.android.wysiwyg.EditorEditText
import io.element.android.wysiwyg.view.models.InlineFormat
import uniffi.wysiwyg_composer.ActionState
import uniffi.wysiwyg_composer.ComposerAction
import io.element.android.libraries.resources.R as ElementR
import io.element.android.libraries.ui.strings.R as StringR
// Imported from Element Android
class RichTextComposerLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr), MessageComposerView {
private val views: ComposerRichTextLayoutBinding
override var callback: Callback? = null
// There is no need to persist these values since they're always updated by the parent fragment
private var isFullScreen = false
private var hasRelatedMessage = false
private var composerMode: MessageComposerMode? = null
var isTextFormattingEnabled = true
set(value) {
if (field == value) return
syncEditTexts()
field = value
updateTextFieldBorder(isFullScreen)
updateEditTextVisibility()
updateFullScreenButtonVisibility()
// If formatting is no longer enabled and it's in full screen, minimise the editor
if (!value && isFullScreen) {
callback?.onFullScreenModeChanged()
}
}
override val text: Editable?
get() = editText.text
override val formattedText: String?
get() = (editText as? EditorEditText)?.getHtmlOutput()
override val editText: EditText
get() = if (isTextFormattingEnabled) {
views.richTextComposerEditText
} else {
views.plainTextComposerEditText
}
override val emojiButton: ImageButton?
get() = null
override val sendButton: ImageButton
get() = views.sendButton
override val attachmentButton: ImageButton
get() = views.attachmentButton
// Border of the EditText
private val borderShapeDrawable: MaterialShapeDrawable by lazy {
MaterialShapeDrawable().apply {
val typedData = TypedValue()
val lineColor = context.theme.obtainStyledAttributes(
typedData.data,
intArrayOf(ElementR.attr.vctr_content_quaternary)
)
.getColor(0, 0)
strokeColor = ColorStateList.valueOf(lineColor)
strokeWidth = 1 * resources.displayMetrics.scaledDensity
fillColor = ColorStateList.valueOf(Color.TRANSPARENT)
val cornerSize =
resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line)
setCornerSize(cornerSize.toFloat())
}
}
private val dimensionConverter = DimensionConverter(resources)
fun setFullScreen(isFullScreen: Boolean, animated: Boolean, manageKeyboard: Boolean) {
if (!animated && views.composerLayout.layoutParams != null) {
views.composerLayout.updateLayoutParams<ViewGroup.LayoutParams> {
height =
if (isFullScreen) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT
}
}
editText.updateLayoutParams<ViewGroup.LayoutParams> {
height = if (isFullScreen) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT
}
updateTextFieldBorder(isFullScreen)
updateEditTextVisibility()
updateEditTextFullScreenState(views.richTextComposerEditText, isFullScreen)
updateEditTextFullScreenState(views.plainTextComposerEditText, isFullScreen)
views.composerFullScreenButton.setImageResource(
if (isFullScreen) R.drawable.ic_composer_collapse else R.drawable.ic_composer_full_screen
)
views.bottomSheetHandle.isVisible = false // EAx: always gone, we do not have a bottom sheet here. // isFullScreen
if (manageKeyboard) {
if (isFullScreen) {
editText.showKeyboard(true)
} else {
editText.hideKeyboard()
}
}
this.isFullScreen = isFullScreen
}
fun notifyIsBeingDragged(percentage: Float) {
// Calculate a new shape for the border according to the position in screen
val isSingleLine = editText.lineCount == 1
val cornerSize = if (!isSingleLine || hasRelatedMessage) {
resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded)
.toFloat()
} else {
val multilineCornerSize =
resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded)
val singleLineCornerSize =
resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line)
val diff = singleLineCornerSize - multilineCornerSize
multilineCornerSize + diff * (1 - percentage)
}
if (cornerSize != borderShapeDrawable.bottomLeftCornerResolvedSize) {
borderShapeDrawable.setCornerSize(cornerSize)
}
// Change maxLines while dragging, this should improve the smoothness of animations
val maxLines = if (percentage > 0.25f) {
Int.MAX_VALUE
} else {
MessageComposerView.MAX_LINES_WHEN_COLLAPSED
}
views.richTextComposerEditText.maxLines = maxLines
views.plainTextComposerEditText.maxLines = maxLines
views.bottomSheetHandle.isVisible = false // EAx: always gone, we do not have a bottom sheet here.
}
init {
inflate(context, R.layout.composer_rich_text_layout, this)
views = ComposerRichTextLayoutBinding.bind(this)
// Workaround to avoid cut-off text caused by padding in scrolled TextView (there is no clipToPadding).
// In TextView, clipTop = padding, but also clipTop -= shadowRadius. So if we set the shadowRadius to padding, they cancel each other
views.richTextComposerEditText.setShadowLayer(
views.richTextComposerEditText.paddingBottom.toFloat(),
0f,
0f,
0
)
views.plainTextComposerEditText.setShadowLayer(
views.richTextComposerEditText.paddingBottom.toFloat(),
0f,
0f,
0
)
renderComposerMode(MessageComposerMode.Normal(null))
views.richTextComposerEditText.addTextChangedListener(
TextChangeListener(
{ callback?.onTextChanged(it) },
{ updateTextFieldBorder(isFullScreen) })
)
views.plainTextComposerEditText.addTextChangedListener(
TextChangeListener(
{ callback?.onTextChanged(it) },
{ updateTextFieldBorder(isFullScreen) })
)
disallowParentInterceptTouchEvent(views.richTextComposerEditText)
disallowParentInterceptTouchEvent(views.plainTextComposerEditText)
views.composerModeCloseView.setOnClickListener {
callback?.onCloseRelatedMessage()
}
views.sendButton.setOnClickListener {
val textMessage =
views.richTextComposerEditText.getMarkdown() // text?.toSpannable() ?: ""
callback?.onSendMessage(textMessage)
}
views.attachmentButton.setOnClickListener {
callback?.onAddAttachment()
}
views.composerFullScreenButton.apply {
updateFullScreenButtonVisibility()
setOnClickListener {
callback?.onFullScreenModeChanged()
}
}
views.composerEditTextOuterBorder.background = borderShapeDrawable
setupRichTextMenu()
updateTextFieldBorder(isFullScreen)
}
private fun setupRichTextMenu() {
addRichTextMenuItem(
R.drawable.ic_composer_bold,
R.string.rich_text_editor_format_bold,
ComposerAction.BOLD
) {
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Bold)
}
addRichTextMenuItem(
R.drawable.ic_composer_italic,
R.string.rich_text_editor_format_italic,
ComposerAction.ITALIC
) {
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Italic)
}
addRichTextMenuItem(
R.drawable.ic_composer_underlined,
R.string.rich_text_editor_format_underline,
ComposerAction.UNDERLINE
) {
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Underline)
}
addRichTextMenuItem(
R.drawable.ic_composer_strikethrough,
R.string.rich_text_editor_format_strikethrough,
ComposerAction.STRIKE_THROUGH
) {
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.StrikeThrough)
}
}
@SuppressLint("ClickableViewAccessibility")
private fun disallowParentInterceptTouchEvent(view: View) {
view.setOnTouchListener { v, event ->
if (v.hasFocus()) {
v.parent?.requestDisallowInterceptTouchEvent(true)
val action = event.actionMasked
if (action == MotionEvent.ACTION_SCROLL) {
v.parent?.requestDisallowInterceptTouchEvent(false)
return@setOnTouchListener true
}
}
false
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
views.richTextComposerEditText.actionStatesChangedListener =
EditorEditText.OnActionStatesChangedListener { state ->
for (action in state.keys) {
updateMenuStateFor(action, state)
}
}
updateEditTextVisibility()
}
private fun updateEditTextVisibility() {
views.richTextComposerEditText.isVisible = isTextFormattingEnabled
views.richTextMenu.isVisible = isTextFormattingEnabled
views.plainTextComposerEditText.isVisible = !isTextFormattingEnabled
// The layouts for formatted text mode and plain text mode are different, so we need to update the constraints
val dpToPx = { dp: Int -> dimensionConverter.dpToPx(dp) }
ConstraintSet().apply {
clone(views.composerLayoutContent)
clear(R.id.composerEditTextOuterBorder, ConstraintSet.TOP)
clear(R.id.composerEditTextOuterBorder, ConstraintSet.BOTTOM)
clear(R.id.composerEditTextOuterBorder, ConstraintSet.START)
clear(R.id.composerEditTextOuterBorder, ConstraintSet.END)
if (isTextFormattingEnabled) {
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.TOP,
R.id.composerLayoutContent,
ConstraintSet.TOP,
dpToPx(8)
)
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.BOTTOM,
R.id.sendButton,
ConstraintSet.TOP,
0
)
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.START,
R.id.composerLayoutContent,
ConstraintSet.START,
dpToPx(12)
)
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.END,
R.id.composerLayoutContent,
ConstraintSet.END,
dpToPx(12)
)
} else {
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.TOP,
R.id.composerLayoutContent,
ConstraintSet.TOP,
dpToPx(10)
)
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.BOTTOM,
R.id.composerLayoutContent,
ConstraintSet.BOTTOM,
dpToPx(10)
)
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.START,
R.id.attachmentButton,
ConstraintSet.END,
0
)
connect(
R.id.composerEditTextOuterBorder,
ConstraintSet.END,
R.id.sendButton,
ConstraintSet.START,
0
)
}
applyTo(views.composerLayoutContent)
}
}
private fun updateFullScreenButtonVisibility() {
val isLargeScreenDevice =
resources.configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
// There's no point in having full screen in landscape since there's almost no vertical space
views.composerFullScreenButton.isInvisible =
!isTextFormattingEnabled || (isLandscape && !isLargeScreenDevice)
}
/**
* Updates the non-active input with the contents of the active input.
*/
private fun syncEditTexts() =
if (isTextFormattingEnabled) {
views.plainTextComposerEditText.setText(views.richTextComposerEditText.getMarkdown())
} else {
views.richTextComposerEditText.setMarkdown(views.plainTextComposerEditText.text.toString())
}
private fun addRichTextMenuItem(
@DrawableRes iconId: Int,
@StringRes description: Int,
action: ComposerAction,
onClick: () -> Unit
) {
val inflater = LayoutInflater.from(context)
val button = ViewRichTextMenuButtonBinding.inflate(inflater, views.richTextMenu, true)
button.root.tag = action
with(button.root) {
contentDescription = resources.getString(description)
setImageResource(iconId)
setOnClickListener {
onClick()
}
}
}
private fun updateMenuStateFor(
action: ComposerAction,
menuState: Map<ComposerAction, ActionState>
) {
val button = findViewWithTag<ImageButton>(action) ?: return
val stateForAction = menuState[action]
button.isEnabled = stateForAction != ActionState.DISABLED
button.isSelected = stateForAction == ActionState.REVERSED
}
fun estimateCollapsedHeight(): Int {
val editText = this.editText
val originalLines = editText.maxLines
val originalParamsHeight = editText.layoutParams.height
editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED
editText.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.UNSPECIFIED,
)
val result = measuredHeight
editText.layoutParams.height = originalParamsHeight
editText.maxLines = originalLines
return result
}
private fun updateTextFieldBorder(isFullScreen: Boolean) {
val isMultiline =
editText.editableText.lines().count() > 1 || isFullScreen || hasRelatedMessage
val cornerSize = if (isMultiline) {
resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded)
} else {
resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line)
}.toFloat()
borderShapeDrawable.setCornerSize(cornerSize)
}
private fun replaceFormattedContent(text: CharSequence) {
views.richTextComposerEditText.setHtml(text.toString())
updateTextFieldBorder(isFullScreen)
}
override fun setTextIfDifferent(text: CharSequence?): Boolean {
val result = editText.setTextIfDifferent(text)
updateTextFieldBorder(isFullScreen)
return result
}
private fun updateEditTextFullScreenState(editText: EditText, isFullScreen: Boolean) {
if (isFullScreen) {
editText.maxLines = Int.MAX_VALUE
} else {
editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED
}
}
override fun renderComposerMode(mode: MessageComposerMode) {
if (this.composerMode == mode) return
this.composerMode = mode
if (mode is MessageComposerMode.Special) {
views.composerModeGroup.isVisible = true
replaceFormattedContent(mode.defaultContent)
hasRelatedMessage = true
editText.showKeyboard(andRequestFocus = true)
} else {
views.composerModeGroup.isGone = true
(mode as? MessageComposerMode.Normal)?.content?.let { text ->
// TODO un-comment once we update to a version of the lib > 0.8.0
/*
if (isTextFormattingEnabled) {
replaceFormattedContent(text)
} else {
views.plainTextComposerEditText.setText(text)
}
*/
views.plainTextComposerEditText.setText(text)
}
views.sendButton.contentDescription = resources.getString(StringR.string.action_send)
hasRelatedMessage = false
}
views.sendButton.apply {
if (mode is MessageComposerMode.Edit) {
contentDescription = resources.getString(StringR.string.action_save)
setImageResource(R.drawable.ic_composer_rich_text_save)
} else {
contentDescription = resources.getString(StringR.string.action_send)
setImageResource(R.drawable.ic_rich_composer_send)
}
}
updateTextFieldBorder(isFullScreen)
when (mode) {
is MessageComposerMode.Edit -> {
views.composerModeTitleView.setText(R.string.editing)
views.composerModeIconView.setImageResource(R.drawable.ic_composer_rich_text_editor_edit)
}
is MessageComposerMode.Quote -> {
views.composerModeTitleView.setText(R.string.quoting)
views.composerModeIconView.setImageResource(R.drawable.ic_quote)
}
is MessageComposerMode.Reply -> {
val userName = mode.senderName
views.composerModeTitleView.text =
resources.getString(R.string.replying_to, userName)
views.composerModeIconView.setImageResource(R.drawable.ic_reply)
}
else -> Unit
}
}
private class TextChangeListener(
private val onTextChanged: (s: Editable) -> Unit,
private val onExpandedChanged: (isExpanded: Boolean) -> Unit,
) : TextWatcher {
private var previousTextWasExpanded = false
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable) {
onTextChanged.invoke(s)
val isExpanded = s.lines().count() > 1
if (previousTextWasExpanded != isExpanded) {
onExpandedChanged(isExpanded)
}
previousTextWasExpanded = isExpanded
}
}
}

View File

@@ -16,140 +16,275 @@
package io.element.android.libraries.textcomposer
import android.graphics.Color
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TextComposer(
fullscreen: Boolean,
composerText: String?,
composerMode: MessageComposerMode,
composerCanSendMessage: Boolean,
isInDarkMode: Boolean,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = FocusRequester(),
onSendMessage: (String) -> Unit = {},
onFullscreenToggle: () -> Unit = {},
onCloseSpecialMode: () -> Unit = {},
onResetComposerMode: () -> Unit = {},
onComposerTextChange: (CharSequence) -> Unit = {},
onAddAttachment:() -> Unit = {},
) {
if (LocalInspectionMode.current) {
FakeComposer(modifier)
} else {
val focusRequester = FocusRequester()
AndroidView(
modifier = modifier.focusRequester(focusRequester),
factory = { context ->
RichTextComposerLayout(context).apply {
// Sets up listeners for View -> Compose communication
this.callback = object : Callback {
override fun onRichContentSelected(contentUri: Uri): Boolean {
return false
}
override fun onTextChanged(text: CharSequence) {
onComposerTextChange(text)
}
override fun onCloseRelatedMessage() {
onCloseSpecialMode()
}
override fun onSendMessage(text: CharSequence) {
// text contains markdown.
onSendMessage(text.toString())
}
override fun onAddAttachment() {
onAddAttachment()
}
override fun onExpandOrCompactChange() {
}
override fun onFullScreenModeChanged() {
onFullscreenToggle()
}
}
setFullScreen(fullscreen, animated = false, manageKeyboard = true)
(this as MessageComposerView).apply {
setup(isInDarkMode, composerMode)
}
}
},
update = { view ->
// View's been inflated or state read in this block has been updated
// Add logic here if necessary
// As selectedItem is read here, AndroidView will recompose
// whenever the state changes
// Example of Compose -> View communication
val messageComposerView = (view as MessageComposerView)
view.setFullScreen(fullscreen, animated = false, manageKeyboard = false)
messageComposerView.renderComposerMode(composerMode)
messageComposerView.sendButton.isInvisible = !composerCanSendMessage
messageComposerView.setTextIfDifferent(composerText ?: "")
messageComposerView.editText.requestFocus()
val text = composerText.orEmpty()
Row(modifier.padding(
horizontal = 12.dp,
vertical = 8.dp
), verticalAlignment = Alignment.Bottom) {
AttachmentButton(onClick = onAddAttachment, modifier = Modifier.padding(vertical = 6.dp))
Spacer(modifier = Modifier.width(12.dp))
var lineCount by remember { mutableStateOf(0) }
val roundedCorners = remember(lineCount, composerMode) {
if (lineCount > 1 || composerMode is MessageComposerMode.Special) {
RoundedCornerShape(20.dp)
} else {
RoundedCornerShape(28.dp)
}
)
}
val minHeight = 42.dp
Column(
modifier = Modifier
.fillMaxWidth()
.clip(roundedCorners)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, roundedCorners)
) {
if (composerMode is MessageComposerMode.Special) {
ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode)
}
val defaultTypography = ElementTextStyles.Regular.callout.copy(textAlign = TextAlign.Start)
Box {
BasicTextField(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = minHeight)
.focusRequester(focusRequester),
value = text,
onValueChange = { onComposerTextChange(it) },
onTextLayout = {
lineCount = it.lineCount
},
textStyle = defaultTypography.copy(color = MaterialTheme.colorScheme.primary),
cursorBrush = SolidColor(LocalColors.current.accentColor),
decorationBox = { innerTextField ->
TextFieldDefaults.DecorationBox(
value = text,
innerTextField = innerTextField,
enabled = true,
singleLine = false,
visualTransformation = VisualTransformation.None,
shape = roundedCorners,
contentPadding = PaddingValues(top = 10.dp, bottom = 10.dp, start = 12.dp, end = 42.dp),
interactionSource = remember { MutableInteractionSource() },
placeholder = {
Text(stringResource(StringR.string.common_message), style = defaultTypography)
},
colors = TextFieldDefaults.colors(
unfocusedTextColor = MaterialTheme.colorScheme.secondary,
focusedTextColor = MaterialTheme.colorScheme.primary,
unfocusedPlaceholderColor = MaterialTheme.colorScheme.secondary,
focusedPlaceholderColor = MaterialTheme.colorScheme.secondary,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
errorContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
)
)
}
)
SendButton(
text = text,
canSendMessage = composerCanSendMessage,
onSendMessage = onSendMessage,
composerMode = composerMode,
modifier = Modifier.padding(end = 6.dp, bottom = 6.dp)
)
}
}
}
}
@Composable
private fun FakeComposer(
private fun ComposerModeView(
composerMode: MessageComposerMode,
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
// AndroidView is not Available in this mode, just render a Text
Box(
modifier = modifier
.fillMaxWidth()
.height(80.dp)
) {
Text(
modifier = Modifier
.align(Alignment.Center),
textAlign = TextAlign.Center,
text = "Composer Preview",
fontSize = 20.sp,
color = MaterialTheme.colorScheme.secondary,
)
when (composerMode) {
is MessageComposerMode.Edit -> {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)) {
Icon(
resourceId = VectorIcons.Edit,
contentDescription = stringResource(R.string.editing),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(16.dp),
)
Text(
stringResource(R.string.editing),
style = ElementTextStyles.Regular.caption2,
textAlign = TextAlign.Start,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(StringR.string.action_close),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.size(16.dp)
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = MutableInteractionSource(),
indication = rememberRipple(bounded = false)
),
)
}
}
else -> Unit
}
}
private fun MessageComposerView.setup(isDarkMode: Boolean, composerMode: MessageComposerMode) {
val editTextColor = if (isDarkMode) {
Color.WHITE
} else {
Color.BLACK
@Composable
private fun AttachmentButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier) {
Surface(
Modifier
.size(30.dp)
.clickable(true, onClick = onClick),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary
) {
Image(
modifier = Modifier.size(12.5f.dp),
painter = painterResource(R.drawable.ic_add_attachment),
contentDescription = null,
contentScale = ContentScale.Inside,
colorFilter = ColorFilter.tint(
LocalContentColor.current
)
)
}
}
}
@Composable
private fun BoxScope.SendButton(
text: String,
canSendMessage: Boolean,
onSendMessage: (String) -> Unit,
composerMode: MessageComposerMode,
modifier: Modifier = Modifier,
) {
val interactionSource = MutableInteractionSource()
Box(
modifier = modifier
.clip(CircleShape)
.background(if (canSendMessage) LocalColors.current.accentColor else Color.Transparent)
.size(30.dp)
.align(Alignment.BottomEnd)
.applyIf(composerMode !is MessageComposerMode.Edit, ifTrue = {
padding(start = 1.dp) // Center the arrow in the circle
})
.clickable(
enabled = canSendMessage,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false),
onClick = {
onSendMessage(text)
}),
contentAlignment = Alignment.Center,
) {
val iconId = when (composerMode) {
is MessageComposerMode.Edit -> R.drawable.ic_tick
else -> R.drawable.ic_send
}
val contentDescription = when (composerMode) {
is MessageComposerMode.Edit -> stringResource(StringR.string.action_edit)
else -> stringResource(StringR.string.action_send)
}
Icon(
modifier = Modifier.size(16.dp),
resourceId = iconId,
contentDescription = contentDescription,
tint = if (canSendMessage) Color.White else LocalColors.current.quaternary
)
}
editText.setTextColor(editTextColor)
editText.setHintTextColor(editTextColor)
editText.setHint(R.string.rich_text_editor_composer_placeholder)
emojiButton?.isVisible = true
sendButton.isVisible = true
editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED
renderComposerMode(composerMode)
}
@Preview
@@ -162,15 +297,38 @@ internal fun TextComposerDarkPreview() = ElementPreviewDark { ContentToPreview()
@Composable
private fun ContentToPreview() {
TextComposer(
onSendMessage = {},
fullscreen = false,
onFullscreenToggle = { },
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onCloseSpecialMode = {},
composerCanSendMessage = true,
composerText = "Message",
isInDarkMode = true,
)
Column {
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
composerCanSendMessage = false,
composerText = "",
)
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
)
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text"),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
}
}

View File

@@ -1,55 +0,0 @@
/*
* 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.tools
import android.text.Spanned
import android.widget.EditText
fun EditText.setTextIfDifferent(newText: CharSequence?): Boolean {
if (!isTextDifferent(newText, text)) {
// Previous text is the same. No op
return false
}
setText(newText)
// Since the text changed we move the cursor to the end of the new text.
// This allows us to fill in text programmatically with a different value,
// but if the user is typing and the view is rebound we won't lose their cursor position.
setSelection(newText?.length ?: 0)
return true
}
private fun isTextDifferent(str1: CharSequence?, str2: CharSequence?): Boolean {
if (str1 === str2) {
return false
}
if (str1 == null || str2 == null) {
return true
}
val length = str1.length
if (length != str2.length) {
return true
}
if (str1 is Spanned) {
return str1 != str2
}
for (i in 0 until length) {
if (str1[i] != str2[i]) {
return true
}
}
return false
}

View File

@@ -1,41 +0,0 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer.tools
import androidx.transition.Transition
open class SimpleTransitionListener : Transition.TransitionListener {
override fun onTransitionEnd(transition: Transition) {
// No op
}
override fun onTransitionResume(transition: Transition) {
// No op
}
override fun onTransitionPause(transition: Transition) {
// No op
}
override fun onTransitionCancel(transition: Transition) {
// No op
}
override fun onTransitionStart(transition: Transition) {
// No op
}
}

View File

@@ -1,39 +0,0 @@
/*
* 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.tools
import android.view.ViewGroup
import androidx.transition.ChangeBounds
import androidx.transition.Fade
import androidx.transition.Transition
import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
fun ViewGroup.animateLayoutChange(animationDuration: Long, transitionComplete: (() -> Unit)? = null) {
val transition = TransitionSet().apply {
ordering = TransitionSet.ORDERING_SEQUENTIAL
addTransition(ChangeBounds())
addTransition(Fade(Fade.IN))
duration = animationDuration
addListener(object : SimpleTransitionListener() {
override fun onTransitionEnd(transition: Transition) {
transitionComplete?.invoke()
}
})
}
TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition)
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,773Q457.91,773 441.46,756.54Q425,740.09 425,718.31L425,535L241.69,535Q219.91,535 203.46,518.54Q187,502.09 187,480Q187,457.91 203.46,441.46Q219.91,425 241.69,425L425,425L425,241.69Q425,219.91 441.46,203.46Q457.91,187 480,187Q502.09,187 518.54,203.46Q535,219.91 535,241.69L535,425L718.31,425Q740.09,425 756.54,441.46Q773,457.91 773,480Q773,502.09 756.54,518.54Q740.09,535 718.31,535L535,535L535,718.31Q535,740.09 518.54,756.54Q502.09,773 480,773Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M15.404,8.965L1.563,15.882C0.631,16.348 -0.34,15.348 0.116,14.435C0.116,14.435 1.832,10.971 2.303,10.064C2.775,9.156 3.315,8.999 8.331,8.351C8.517,8.327 8.669,8.187 8.669,8C8.669,7.813 8.517,7.673 8.331,7.649C3.315,7.001 2.775,6.844 2.303,5.936C1.832,5.029 0.116,1.565 0.116,1.565C-0.34,0.653 0.631,-0.348 1.563,0.118L15.404,7.036C16.199,7.433 16.199,8.567 15.404,8.965Z"
android:fillColor="#A6ADB7"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="15dp"
android:viewportWidth="16"
android:viewportHeight="15">
<path
android:pathData="M6.518,14.779C6.953,14.779 7.297,14.597 7.535,14.233L15.403,1.968C15.579,1.692 15.65,1.461 15.65,1.234C15.65,0.657 15.245,0.26 14.662,0.26C14.249,0.26 14.009,0.399 13.759,0.792L6.484,12.348L2.736,7.529C2.492,7.205 2.236,7.07 1.874,7.07C1.277,7.07 0.857,7.489 0.857,8.066C0.857,8.315 0.95,8.565 1.158,8.819L5.495,14.245C5.784,14.606 6.096,14.779 6.518,14.779Z"
android:fillColor="#ffffff"/>
</vector>

View File

@@ -98,6 +98,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:mediapickers:impl"))
implementation(project(":libraries:mediaupload:impl"))
implementation(project(":libraries:usersearch:impl"))
implementation(project(":libraries:textcomposer"))
}
fun DependencyHandlerScope.allServicesImpl() {