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 @@ |
|||||||
|
Add support for typing mentions in the message composer. |
@ -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 @@ |
|||||||
|
/* |
||||||
|
* 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 @@ |
|||||||
|
/* |
||||||
|
* 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) |
||||||
|
} |
||||||
|
} |
Binary file not shown.
Loading…
Reference in new issue