Browse Source
* 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>pull/1844/head
Jorge Martin Espinosa
10 months ago
committed by
GitHub
21 changed files with 465 additions and 61 deletions
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
Add support for typing mentions in the message composer. |
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
/* |
||||
* 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.features.messages.impl.mentions |
||||
|
||||
import androidx.compose.runtime.Immutable |
||||
import io.element.android.libraries.matrix.api.room.RoomMember |
||||
|
||||
@Immutable |
||||
sealed interface MentionSuggestion { |
||||
data object Room : MentionSuggestion |
||||
data class Member(val roomMember: RoomMember) : MentionSuggestion |
||||
} |
@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
|
||||
/* |
||||
* 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.core.data |
||||
|
||||
/** |
||||
* Returns a list containing first [count] elements matching the given [predicate]. |
||||
* If the list contains less elements matching the [predicate], then all of them are returned. |
||||
* |
||||
* @param T the type of elements contained in the list. |
||||
* @param count the maximum number of elements to take. |
||||
* @param predicate the predicate used to match elements. |
||||
* @return a list containing first [count] elements matching the given [predicate]. |
||||
*/ |
||||
inline fun <T> Iterable<T>.filterUpTo(count: Int, predicate: (T) -> Boolean): List<T> { |
||||
val result = mutableListOf<T>() |
||||
for (element in this) { |
||||
if (predicate(element)) { |
||||
result.add(element) |
||||
if (result.size == count) { |
||||
break |
||||
} |
||||
} |
||||
} |
||||
return result |
||||
} |
@ -0,0 +1,42 @@
@@ -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 @@
@@ -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 @@
@@ -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) |
||||
} |
||||
} |
Binary file not shown.
Loading…
Reference in new issue