Integrate mentions in the composer (#1799)
* Integrate mentions in the composer:
- Add `MentionSpanProvider`.
- Add custom colors needed for mentions.
- Use the span provider to render mentions in the composer.
- Allow selecting users from the mentions suggestions to insert a mention.
---------
Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
004804a7c8
commit
a8fbb882f2
21 changed files with 465 additions and 61 deletions
|
|
@ -44,6 +44,7 @@ import androidx.compose.runtime.remember
|
|||
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.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
|
@ -57,6 +58,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
|||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
|
|
@ -73,6 +75,7 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteBu
|
|||
import io.element.android.libraries.textcomposer.components.VoiceMessagePreview
|
||||
import io.element.android.libraries.textcomposer.components.VoiceMessageRecording
|
||||
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
|
||||
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||
|
|
@ -81,8 +84,10 @@ import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
|||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.compose.PillStyle
|
||||
import io.element.android.wysiwyg.compose.RichTextEditor
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import io.element.android.wysiwyg.display.TextDisplay
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import uniffi.wysiwyg_composer.MenuAction
|
||||
|
|
@ -95,6 +100,7 @@ fun TextComposer(
|
|||
composerMode: MessageComposerMode,
|
||||
enableTextFormatting: Boolean,
|
||||
enableVoiceMessages: Boolean,
|
||||
currentUserId: UserId,
|
||||
modifier: Modifier = Modifier,
|
||||
showTextFormatting: Boolean = false,
|
||||
subcomposing: Boolean = false,
|
||||
|
|
@ -143,6 +149,7 @@ fun TextComposer(
|
|||
|
||||
val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) {
|
||||
@Composable {
|
||||
val mentionSpanProvider = rememberMentionSpanProvider(currentUserId)
|
||||
TextInput(
|
||||
state = state,
|
||||
subcomposing = subcomposing,
|
||||
|
|
@ -153,6 +160,8 @@ fun TextComposer(
|
|||
},
|
||||
composerMode = composerMode,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) },
|
||||
resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) },
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
|
|
@ -385,6 +394,8 @@ private fun TextInput(
|
|||
placeholder: String,
|
||||
composerMode: MessageComposerMode,
|
||||
onResetComposerMode: () -> Unit,
|
||||
resolveRoomMentionDisplay: () -> TextDisplay,
|
||||
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
|
||||
modifier: Modifier = Modifier,
|
||||
onError: (Throwable) -> Unit = {},
|
||||
) {
|
||||
|
|
@ -432,7 +443,11 @@ private fun TextInput(
|
|||
.fillMaxWidth(),
|
||||
style = ElementRichTextEditorStyle.create(
|
||||
hasFocus = state.hasFocus
|
||||
).copy(
|
||||
pill = PillStyle(Color.Red)
|
||||
),
|
||||
resolveMentionDisplay = resolveMentionDisplay,
|
||||
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
|
|
@ -584,6 +599,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
|||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost"),
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
|
|
@ -594,6 +610,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
|||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
|
|
@ -607,6 +624,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
|||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
|
|
@ -617,6 +635,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
|||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
})
|
||||
)
|
||||
|
|
@ -633,6 +652,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
|
|||
composerMode = MessageComposerMode.Normal,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
|
|
@ -642,6 +662,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
|
|||
composerMode = MessageComposerMode.Normal,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
|
|
@ -651,6 +672,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
|
|||
composerMode = MessageComposerMode.Normal,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
|
@ -667,6 +689,7 @@ internal fun TextComposerEditPreview() = ElementPreview {
|
|||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
|
@ -691,6 +714,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
|||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
},
|
||||
{
|
||||
|
|
@ -710,6 +734,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
|||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
|
|
@ -731,6 +756,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
|||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
|
|
@ -752,6 +778,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
|||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
|
|
@ -773,6 +800,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
|||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
|
|
@ -794,6 +822,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
|||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
})
|
||||
)
|
||||
|
|
@ -813,6 +842,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
|
|||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
currentUserId = UserId("@alice:localhost")
|
||||
)
|
||||
PreviewColumn(items = persistentListOf({
|
||||
VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, createFakeWaveform()))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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.mentions
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.text.style.ReplacementSpan
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MentionSpan(
|
||||
val backgroundColor: Int,
|
||||
val textColor: Int,
|
||||
) : ReplacementSpan() {
|
||||
|
||||
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
|
||||
return paint.measureText(text, start, end).roundToInt() + 40
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
|
||||
val textSize = paint.measureText(text, start, end)
|
||||
val rect = RectF(x, top.toFloat(), x + textSize + 40, bottom.toFloat())
|
||||
paint.color = backgroundColor
|
||||
canvas.drawRoundRect(rect, rect.height() / 2, rect.height() / 2, paint)
|
||||
paint.color = textColor
|
||||
canvas.drawText(text!!, start, end, x + 20, y.toFloat(), paint)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* 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.mentions
|
||||
|
||||
import android.graphics.Color
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.text.buildSpannedString
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.currentUserMentionPillBackground
|
||||
import io.element.android.libraries.designsystem.theme.currentUserMentionPillText
|
||||
import io.element.android.libraries.designsystem.theme.mentionPillBackground
|
||||
import io.element.android.libraries.designsystem.theme.mentionPillText
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Stable
|
||||
class MentionSpanProvider(
|
||||
private val currentSessionId: SessionId,
|
||||
private var currentUserTextColor: Int = 0,
|
||||
private var currentUserBackgroundColor: Int = Color.WHITE,
|
||||
private var otherTextColor: Int = 0,
|
||||
private var otherBackgroundColor: Int = Color.WHITE,
|
||||
) {
|
||||
|
||||
@Suppress("ComposableNaming")
|
||||
@Composable
|
||||
internal fun setup() {
|
||||
currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb()
|
||||
currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb()
|
||||
otherTextColor = ElementTheme.colors.mentionPillText.toArgb()
|
||||
otherBackgroundColor = ElementTheme.colors.mentionPillBackground.toArgb()
|
||||
}
|
||||
|
||||
fun getMentionSpanFor(text: String, url: String): MentionSpan {
|
||||
val permalinkData = PermalinkParser.parse(url)
|
||||
return when {
|
||||
permalinkData is PermalinkData.UserLink -> {
|
||||
val isCurrentUser = permalinkData.userId == currentSessionId.value
|
||||
MentionSpan(
|
||||
backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor,
|
||||
textColor = if (isCurrentUser) currentUserTextColor else otherTextColor,
|
||||
)
|
||||
}
|
||||
text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
|
||||
MentionSpan(
|
||||
backgroundColor = otherBackgroundColor,
|
||||
textColor = otherTextColor,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
MentionSpan(
|
||||
backgroundColor = otherBackgroundColor,
|
||||
textColor = otherTextColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberMentionSpanProvider(currentUserId: SessionId): MentionSpanProvider {
|
||||
val provider = remember(currentUserId) {
|
||||
MentionSpanProvider(currentUserId)
|
||||
}
|
||||
provider.setup()
|
||||
return provider
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MentionSpanPreview() {
|
||||
val provider = rememberMentionSpanProvider(SessionId("@me:matrix.org"))
|
||||
ElementPreview {
|
||||
provider.setup()
|
||||
|
||||
val textColor = ElementTheme.colors.textPrimary.toArgb()
|
||||
val mentionSpan = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org")
|
||||
val mentionSpan2 = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
|
||||
AndroidView(factory = { context ->
|
||||
TextView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
text = buildSpannedString {
|
||||
append("This is a ")
|
||||
append("@mention", mentionSpan, 0)
|
||||
append(" to the current user and this is a ")
|
||||
append("@mention", mentionSpan2, 0)
|
||||
append(" to other user")
|
||||
}
|
||||
setTextColor(textColor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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.impl.mentions
|
||||
|
||||
import android.graphics.Color
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MentionSpanProviderTest {
|
||||
|
||||
@JvmField @Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val myUserColor = Color.RED
|
||||
private val otherColor = Color.BLUE
|
||||
private val currentUserId = A_SESSION_ID
|
||||
|
||||
private val mentionSpanProvider = MentionSpanProvider(
|
||||
currentSessionId = currentUserId,
|
||||
currentUserBackgroundColor = myUserColor,
|
||||
currentUserTextColor = myUserColor,
|
||||
otherBackgroundColor = otherColor,
|
||||
otherTextColor = otherColor,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `getting mention span for current user should return a MentionSpan with custom colors`() {
|
||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor("me", "https://matrix.to/#/${currentUserId.value}")
|
||||
assertThat(mentionSpan.backgroundColor).isEqualTo(myUserColor)
|
||||
assertThat(mentionSpan.textColor).isEqualTo(myUserColor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getting mention span for other user should return a MentionSpan with normal colors`() {
|
||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
|
||||
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
|
||||
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getting mention span for @room should return a MentionSpan with normal colors`() {
|
||||
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#")
|
||||
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
|
||||
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue