Merge pull request #3322 from element-hq/feature/bma/roomAliasCompletion

Suggestion for room alias (disabled for now)
This commit is contained in:
Benoit Marty 2024-08-22 14:47:29 +02:00 committed by GitHub
commit a942fd4a24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 814 additions and 274 deletions

View file

@ -72,6 +72,13 @@ enum class FeatureFlags(
defaultValue = { true },
isFinished = false,
),
RoomAliasSuggestions(
key = "feature.roomAliasSuggestions",
title = "Room alias suggestions",
description = "Type `#` to get room alias suggestions and insert them",
defaultValue = { false },
isFinished = false,
),
MarkAsUnread(
key = "feature.markAsUnread",
title = "Mark as unread",

View file

@ -25,6 +25,5 @@ interface PermalinkBuilder {
}
sealed class PermalinkBuilderError : Throwable() {
data object InvalidUserId : PermalinkBuilderError()
data object InvalidRoomAlias : PermalinkBuilderError()
data object InvalidData : PermalinkBuilderError()
}

View file

@ -16,12 +16,9 @@
package io.element.android.libraries.matrix.api.room
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
sealed interface Mention {
data class User(val userId: UserId) : Mention
data object AtRoom : Mention
data class Room(val roomId: RoomId) : Mention
data class RoomAlias(val roomAlias: RoomAlias?) : Mention
sealed interface IntentionalMention {
data class User(val userId: UserId) : IntentionalMention
data object Room : IntentionalMention
}

View file

@ -129,9 +129,9 @@ interface MatrixRoom : Closeable {
suspend fun userAvatarUrl(userId: UserId): Result<String?>
suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun sendMessage(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): Result<Unit>
suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): Result<Unit>
suspend fun sendImage(
file: File,

View file

@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import kotlinx.coroutines.flow.Flow
@ -52,15 +52,24 @@ interface Timeline : AutoCloseable {
fun paginationStatus(direction: PaginationDirection): StateFlow<PaginationStatus>
val timelineItems: Flow<List<MatrixTimelineItem>>
suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun sendMessage(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
): Result<Unit>
suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun editMessage(
originalEventId: EventId?,
transactionId: TransactionId?,
body: String, htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
): Result<Unit>
suspend fun replyMessage(
eventId: EventId,
body: String,
htmlBody: String?,
mentions: List<Mention>,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean = false,
): Result<Unit>

View file

@ -31,7 +31,7 @@ import javax.inject.Inject
class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder {
override fun permalinkForUser(userId: UserId): Result<String> {
if (!MatrixPatterns.isUserId(userId.value)) {
return Result.failure(PermalinkBuilderError.InvalidUserId)
return Result.failure(PermalinkBuilderError.InvalidData)
}
return runCatching {
matrixToUserPermalink(userId.value)
@ -40,7 +40,7 @@ class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder {
override fun permalinkForRoomAlias(roomAlias: RoomAlias): Result<String> {
if (!MatrixPatterns.isRoomAlias(roomAlias.value)) {
return Result.failure(PermalinkBuilderError.InvalidRoomAlias)
return Result.failure(PermalinkBuilderError.InvalidData)
}
return runCatching {
matrixToRoomAliasPermalink(roomAlias.value)

View file

@ -16,11 +16,11 @@
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.IntentionalMention
import org.matrix.rustcomponents.sdk.Mentions
fun List<Mention>.map(): Mentions {
val hasAtRoom = any { it is Mention.AtRoom }
val userIds = filterIsInstance<Mention.User>().map { it.userId.value }
return Mentions(userIds, hasAtRoom)
fun List<IntentionalMention>.map(): Mentions {
val hasRoom = any { it is IntentionalMention.Room }
val userIds = filterIsInstance<IntentionalMention.User>().map { it.userId.value }
return Mentions(userIds, hasRoom)
}

View file

@ -33,11 +33,11 @@ import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType
@ -340,16 +340,21 @@ class RustMatrixRoom(
}
}
override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(roomDispatcher) {
override suspend fun editMessage(
eventId: EventId,
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>
): Result<Unit> = withContext(roomDispatcher) {
runCatching {
MessageEventContent.from(body, htmlBody, mentions).use { newContent ->
MessageEventContent.from(body, htmlBody, intentionalMentions).use { newContent ->
innerRoom.edit(eventId.value, newContent)
}
}
}
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> {
return liveTimeline.sendMessage(body, htmlBody, mentions)
override suspend fun sendMessage(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): Result<Unit> {
return liveTimeline.sendMessage(body, htmlBody, intentionalMentions)
}
override suspend fun leave(): Result<Unit> = withContext(roomDispatcher) {

View file

@ -26,8 +26,8 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@ -263,8 +263,12 @@ class RustTimeline(
}
}
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(dispatcher) {
MessageEventContent.from(body, htmlBody, mentions).use { content ->
override suspend fun sendMessage(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
): Result<Unit> = withContext(dispatcher) {
MessageEventContent.from(body, htmlBody, intentionalMentions).use { content ->
runCatching<Unit> {
inner.send(content)
}
@ -284,13 +288,13 @@ class RustTimeline(
transactionId: TransactionId?,
body: String,
htmlBody: String?,
mentions: List<Mention>,
intentionalMentions: List<IntentionalMention>,
): Result<Unit> =
withContext(dispatcher) {
runCatching<Unit> {
getEventTimelineItem(originalEventId, transactionId).use { item ->
inner.edit(
newContent = MessageEventContent.from(body, htmlBody, mentions),
newContent = MessageEventContent.from(body, htmlBody, intentionalMentions),
item = item,
)
}
@ -301,11 +305,11 @@ class RustTimeline(
eventId: EventId,
body: String,
htmlBody: String?,
mentions: List<Mention>,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
): Result<Unit> = withContext(dispatcher) {
runCatching {
val msg = MessageEventContent.from(body, htmlBody, mentions)
val msg = MessageEventContent.from(body, htmlBody, intentionalMentions)
inner.sendReply(msg, eventId.value)
}
}

View file

@ -16,7 +16,7 @@
package io.element.android.libraries.matrix.impl.util
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.impl.room.map
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
@ -26,11 +26,11 @@ import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
* Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions.
*/
object MessageEventContent {
fun from(body: String, htmlBody: String?, mentions: List<Mention>): RoomMessageEventContentWithoutRelation {
fun from(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): RoomMessageEventContentWithoutRelation {
return if (htmlBody != null) {
messageEventContentFromHtml(body, htmlBody)
} else {
messageEventContentFromMarkdown(body)
}.withMentions(mentions.map())
}.withMentions(intentionalMentions.map())
}
}

View file

@ -19,10 +19,11 @@ package io.element.android.libraries.matrix.test.permalink
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.tests.testutils.lambda.lambdaError
class FakePermalinkBuilder(
private val permalinkForUserLambda: (UserId) -> Result<String> = { Result.failure(Exception("Not implemented")) },
private val permalinkForRoomAliasLambda: (RoomAlias) -> Result<String> = { Result.failure(Exception("Not implemented")) },
private val permalinkForUserLambda: (UserId) -> Result<String> = { lambdaError() },
private val permalinkForRoomAliasLambda: (RoomAlias) -> Result<String> = { lambdaError() },
) : PermalinkBuilder {
override fun permalinkForUser(userId: UserId): Result<String> {
return permalinkForUserLambda(userId)

View file

@ -31,11 +31,11 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@ -105,8 +105,8 @@ class FakeMatrixRoom(
private val setTopicResult: (String) -> Result<Unit> = { lambdaError() },
private val updateAvatarResult: (String, ByteArray) -> Result<Unit> = { _, _ -> lambdaError() },
private val removeAvatarResult: () -> Result<Unit> = { lambdaError() },
private val editMessageLambda: (EventId, String, String?, List<Mention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() },
private val sendMessageResult: (String, String?, List<Mention>) -> Result<Unit> = { _, _, _ -> lambdaError() },
private val editMessageLambda: (EventId, String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() },
private val sendMessageResult: (String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _ -> lambdaError() },
private val updateUserRoleResult: () -> Result<Unit> = { lambdaError() },
private val toggleReactionResult: (String, EventId) -> Result<Unit> = { _, _ -> lambdaError() },
private val retrySendMessageResult: (TransactionId) -> Result<Unit> = { lambdaError() },
@ -222,12 +222,12 @@ class FakeMatrixRoom(
return updateUserRoleResult()
}
override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>) = simulateLongTask {
editMessageLambda(eventId, body, htmlBody, mentions)
override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>) = simulateLongTask {
editMessageLambda(eventId, body, htmlBody, intentionalMentions)
}
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>) = simulateLongTask {
sendMessageResult(body, htmlBody, mentions)
override suspend fun sendMessage(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>) = simulateLongTask {
sendMessageResult(body, htmlBody, intentionalMentions)
}
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> {

View file

@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType
@ -60,7 +60,7 @@ class FakeTimeline(
var sendMessageLambda: (
body: String,
htmlBody: String?,
mentions: List<Mention>,
intentionalMentions: List<IntentionalMention>,
) -> Result<Unit> = { _, _, _ ->
Result.success(Unit)
}
@ -68,8 +68,8 @@ class FakeTimeline(
override suspend fun sendMessage(
body: String,
htmlBody: String?,
mentions: List<Mention>,
): Result<Unit> = sendMessageLambda(body, htmlBody, mentions)
intentionalMentions: List<IntentionalMention>,
): Result<Unit> = sendMessageLambda(body, htmlBody, intentionalMentions)
var redactEventLambda: (eventId: EventId?, transactionId: TransactionId?, reason: String?) -> Result<Boolean> = { _, _, _ ->
Result.success(true)
@ -86,7 +86,7 @@ class FakeTimeline(
transactionId: TransactionId?,
body: String,
htmlBody: String?,
mentions: List<Mention>,
intentionalMentions: List<IntentionalMention>,
) -> Result<Unit> = { _, _, _, _, _ ->
Result.success(Unit)
}
@ -96,20 +96,20 @@ class FakeTimeline(
transactionId: TransactionId?,
body: String,
htmlBody: String?,
mentions: List<Mention>,
intentionalMentions: List<IntentionalMention>,
): Result<Unit> = editMessageLambda(
originalEventId,
transactionId,
body,
htmlBody,
mentions
intentionalMentions
)
var replyMessageLambda: (
eventId: EventId,
body: String,
htmlBody: String?,
mentions: List<Mention>,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
) -> Result<Unit> = { _, _, _, _, _ ->
Result.success(Unit)
@ -119,13 +119,13 @@ class FakeTimeline(
eventId: EventId,
body: String,
htmlBody: String?,
mentions: List<Mention>,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
): Result<Unit> = replyMessageLambda(
eventId,
body,
htmlBody,
mentions,
intentionalMentions,
fromNotification,
)

View file

@ -171,14 +171,14 @@ class NotificationBroadcastReceiverHandler @Inject constructor(
eventId = threadId.asEventId(),
body = message,
htmlBody = null,
mentions = emptyList(),
intentionalMentions = emptyList(),
fromNotification = true,
)
} else {
room.liveTimeline.sendMessage(
body = message,
htmlBody = null,
mentions = emptyList()
intentionalMentions = emptyList()
)
}.onFailure {
Timber.e(it, "Failed to send smart reply message")

View file

@ -24,7 +24,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -337,8 +337,8 @@ class NotificationBroadcastReceiverHandlerTest {
@Test
fun `Test send reply`() = runTest {
val sendMessage = lambdaRecorder<String, String?, List<Mention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val replyMessage = lambdaRecorder<EventId, String, String?, List<Mention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val replyMessage = lambdaRecorder<EventId, String, String?, List<IntentionalMention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
replyMessageLambda = replyMessage
@ -363,7 +363,7 @@ class NotificationBroadcastReceiverHandlerTest {
runCurrent()
sendMessage.assertions()
.isCalledOnce()
.with(value(A_MESSAGE), value(null), value(emptyList<Mention>()))
.with(value(A_MESSAGE), value(null), value(emptyList<IntentionalMention>()))
onNotifiableEventReceivedResult.assertions()
.isCalledOnce()
replyMessage.assertions()
@ -372,7 +372,7 @@ class NotificationBroadcastReceiverHandlerTest {
@Test
fun `Test send reply blank message`() = runTest {
val sendMessage = lambdaRecorder<String, String?, List<Mention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
}
@ -396,8 +396,8 @@ class NotificationBroadcastReceiverHandlerTest {
@Test
fun `Test send reply to thread`() = runTest {
val sendMessage = lambdaRecorder<String, String?, List<Mention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val replyMessage = lambdaRecorder<EventId, String, String?, List<Mention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val replyMessage = lambdaRecorder<EventId, String, String?, List<IntentionalMention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
replyMessageLambda = replyMessage
@ -427,7 +427,7 @@ class NotificationBroadcastReceiverHandlerTest {
.isCalledOnce()
replyMessage.assertions()
.isCalledOnce()
.with(value(A_THREAD_ID.asEventId()), value(A_MESSAGE), value(null), value(emptyList<Mention>()), value(true))
.with(value(A_THREAD_ID.asEventId()), value(A_MESSAGE), value(null), value(emptyList<IntentionalMention>()), value(true))
}
private fun createIntent(

View file

@ -111,13 +111,13 @@ fun MarkdownTextInput(
state.text.update(editable, false)
state.lineCount = lineCount
state.currentMentionSuggestion = editable?.checkSuggestionNeeded()
onReceiveSuggestion(state.currentMentionSuggestion)
state.currentSuggestion = editable?.checkSuggestionNeeded()
onReceiveSuggestion(state.currentSuggestion)
}
onSelectionChangeListener = { selStart, selEnd ->
state.selection = selStart..selEnd
state.currentMentionSuggestion = editableText.checkSuggestionNeeded()
onReceiveSuggestion(state.currentMentionSuggestion)
state.currentSuggestion = editableText.checkSuggestionNeeded()
onReceiveSuggestion(state.currentSuggestion)
}
if (onSelectRichContent != null) {
ViewCompat.setOnReceiveContentListener(

View file

@ -17,10 +17,13 @@
package io.element.android.libraries.textcomposer.mentions
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
@Immutable
sealed interface ResolvedMentionSuggestion {
data object AtRoom : ResolvedMentionSuggestion
data class Member(val roomMember: RoomMember) : ResolvedMentionSuggestion
sealed interface ResolvedSuggestion {
data object AtRoom : ResolvedSuggestion
data class Member(val roomMember: RoomMember) : ResolvedSuggestion
data class Alias(val roomAlias: RoomAlias, val roomSummary: RoomSummary) : ResolvedSuggestion
}

View file

@ -31,13 +31,14 @@ import androidx.compose.runtime.saveable.SaverScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.core.text.getSpans
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import kotlinx.parcelize.Parcelize
@ -51,16 +52,16 @@ class MarkdownTextEditorState(
var hasFocus by mutableStateOf(initialFocus)
var requestFocusAction by mutableStateOf({})
var lineCount by mutableIntStateOf(1)
var currentMentionSuggestion by mutableStateOf<Suggestion?>(null)
var currentSuggestion by mutableStateOf<Suggestion?>(null)
fun insertMention(
mention: ResolvedMentionSuggestion,
fun insertSuggestion(
resolvedSuggestion: ResolvedSuggestion,
mentionSpanProvider: MentionSpanProvider,
permalinkBuilder: PermalinkBuilder,
) {
val suggestion = currentMentionSuggestion ?: return
when (mention) {
is ResolvedMentionSuggestion.AtRoom -> {
val suggestion = currentSuggestion ?: return
when (resolvedSuggestion) {
is ResolvedSuggestion.AtRoom -> {
val currentText = SpannableStringBuilder(text.value())
val replaceText = "@room"
val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "")
@ -70,10 +71,10 @@ class MarkdownTextEditorState(
text.update(currentText, true)
selection = IntRange(end + 1, end + 1)
}
is ResolvedMentionSuggestion.Member -> {
is ResolvedSuggestion.Member -> {
val currentText = SpannableStringBuilder(text.value())
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return
val text = resolvedSuggestion.roomMember.displayName?.prependIndent("@") ?: resolvedSuggestion.roomMember.userId.value
val link = permalinkBuilder.permalinkForUser(resolvedSuggestion.roomMember.userId).getOrNull() ?: return
val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link)
currentText.replace(suggestion.start, suggestion.end, "@ ")
val end = suggestion.start + 1
@ -81,6 +82,17 @@ class MarkdownTextEditorState(
this.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1)
}
is ResolvedSuggestion.Alias -> {
val currentText = SpannableStringBuilder(text.value())
val text = resolvedSuggestion.roomAlias.value
val link = permalinkBuilder.permalinkForRoomAlias(resolvedSuggestion.roomAlias).getOrNull() ?: return
val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link)
currentText.replace(suggestion.start, suggestion.end, "# ")
val end = suggestion.start + 1
currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
this.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1)
}
}
}
@ -96,14 +108,18 @@ class MarkdownTextEditorState(
val end = charSequence.getSpanEnd(mention)
when (mention.type) {
MentionSpan.Type.USER -> {
val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue
replace(start, end, "[${mention.rawValue}]($link)")
permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull()?.let { link ->
replace(start, end, "[${mention.rawValue}]($link)")
}
}
MentionSpan.Type.EVERYONE -> {
replace(start, end, "@room")
}
// Nothing to do here yet
MentionSpan.Type.ROOM -> Unit
MentionSpan.Type.ROOM -> {
permalinkBuilder.permalinkForRoomAlias(RoomAlias(mention.rawValue)).getOrNull()?.let { link ->
replace(start, end, "[${mention.text}]($link)")
}
}
}
}
}
@ -113,13 +129,13 @@ class MarkdownTextEditorState(
}
}
fun getMentions(): List<Mention> {
fun getMentions(): List<IntentionalMention> {
val text = SpannableString(text.value())
val mentionSpans = text.getSpans<MentionSpan>(0, text.length)
return mentionSpans.mapNotNull { mentionSpan ->
when (mentionSpan.type) {
MentionSpan.Type.USER -> Mention.User(UserId(mentionSpan.rawValue))
MentionSpan.Type.EVERYONE -> Mention.AtRoom
MentionSpan.Type.USER -> IntentionalMention.User(UserId(mentionSpan.rawValue))
MentionSpan.Type.EVERYONE -> IntentionalMention.Room
MentionSpan.Type.ROOM -> null
}
}

View file

@ -16,10 +16,10 @@
package io.element.android.libraries.textcomposer.model
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.IntentionalMention
data class Message(
val html: String?,
val markdown: String,
val mentions: List<Mention>,
val intentionalMentions: List<IntentionalMention>,
)

View file

@ -34,7 +34,7 @@ import io.element.android.libraries.textcomposer.components.markdown.MarkdownTex
import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
@ -157,13 +157,13 @@ class MarkdownTextInputTest {
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/$A_SESSION_ID") })
val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true)
state.currentMentionSuggestion = Suggestion(0, 1, SuggestionType.Mention, "")
state.currentSuggestion = Suggestion(0, 1, SuggestionType.Mention, "")
rule.setMarkdownTextInput(state = state)
var editor: EditText? = null
rule.activityRule.scenario.onActivity {
editor = it.findEditor()
state.insertMention(
ResolvedMentionSuggestion.Member(roomMember = aRoomMember()),
state.insertSuggestion(
ResolvedSuggestion.Member(roomMember = aRoomMember()),
MentionSpanProvider(permalinkParser = permalinkParser),
permalinkBuilder,
)

View file

@ -32,7 +32,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class MentionSpanProviderTest {
class IntentionalMentionSpanProviderTest {
@JvmField @Rule
val warmUpRule = WarmUpRule()

View file

@ -21,14 +21,17 @@ import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
@ -38,68 +41,102 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MarkdownTextEditorStateTest {
@Test
fun `insertMention - with no currentMentionSuggestion does nothing`() {
fun `insertMention - room alias - getMentions return empty list`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
val suggestion = ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary(canonicalAlias = A_ROOM_ALIAS))
val permalinkBuilder = FakePermalinkBuilder()
val mentionSpanProvider = aMentionSpanProvider()
state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder)
assertThat(state.getMentions()).isEmpty()
}
@Test
fun `insertSuggestion - room alias - with member but failed PermalinkBuilder result`() {
val state = MarkdownTextEditorState(initialText = "Hello #", initialFocus = true).apply {
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Room, text = "")
}
val suggestion = ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary(canonicalAlias = A_ROOM_ALIAS))
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.failure(IllegalStateException("Failed")) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder)
}
@Test
fun `insertSuggestion - room alias`() {
val state = MarkdownTextEditorState(initialText = "Hello #", initialFocus = true).apply {
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Room, text = "")
}
val suggestion = ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary(canonicalAlias = A_ROOM_ALIAS))
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.success("https://matrix.to/#/${A_ROOM_ALIAS.value}") })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder)
}
@Test
fun `insertSuggestion - with no currentMentionSuggestion does nothing`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
val member = aRoomMember()
val mention = ResolvedMentionSuggestion.Member(member)
val mention = ResolvedSuggestion.Member(member)
val permalinkBuilder = FakePermalinkBuilder()
val mentionSpanProvider = aMentionSpanProvider()
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder)
assertThat(state.getMentions()).isEmpty()
}
@Test
fun `insertMention - with member but failed PermalinkBuilder result`() {
fun `insertSuggestion - with member but failed PermalinkBuilder result`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
}
val member = aRoomMember()
val mention = ResolvedMentionSuggestion.Member(member)
val mention = ResolvedSuggestion.Member(member)
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.failure(IllegalStateException("Failed")) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder)
val mentions = state.getMentions()
assertThat(mentions).isEmpty()
}
@Test
fun `insertMention - with member`() {
fun `insertSuggestion - with member`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
}
val member = aRoomMember()
val mention = ResolvedMentionSuggestion.Member(member)
val mention = ResolvedSuggestion.Member(member)
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/${member.userId}") })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat((mentions.firstOrNull() as? Mention.User)?.userId).isEqualTo(member.userId)
assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId).isEqualTo(member.userId)
}
@Test
fun `insertMention - with @room`() {
fun `insertSuggestion - with @room`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
}
val mention = ResolvedMentionSuggestion.AtRoom
val mention = ResolvedSuggestion.AtRoom
val permalinkBuilder = FakePermalinkBuilder()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat(mentions.firstOrNull()).isInstanceOf(Mention.AtRoom::class.java)
assertThat(mentions.firstOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
}
@Test
@ -115,14 +152,18 @@ class MarkdownTextEditorStateTest {
@Test
fun `getMessageMarkdown - when there are MentionSpans returns the same text with links to the mentions`() {
val text = "No mentions here"
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/$it") })
val permalinkBuilder = FakePermalinkBuilder(
permalinkForUserLambda = { Result.success("https://matrix.to/#/$it") },
permalinkForRoomAliasLambda = { Result.success("https://matrix.to/#/$it") },
)
val state = MarkdownTextEditorState(initialText = text, initialFocus = true)
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder)
assertThat(markdown).isEqualTo(
"Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room"
"Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room" +
" and a room [#room:domain.org](https://matrix.to/#/#room:domain.org)"
)
}
@ -141,8 +182,8 @@ class MarkdownTextEditorStateTest {
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat((mentions.firstOrNull() as? Mention.User)?.userId?.value).isEqualTo("@alice:matrix.org")
assertThat(mentions.lastOrNull()).isInstanceOf(Mention.AtRoom::class.java)
assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId?.value).isEqualTo("@alice:matrix.org")
assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
}
private fun aMentionSpanProvider(
@ -154,6 +195,7 @@ class MarkdownTextEditorStateTest {
private fun aMarkdownTextWithMentions(): CharSequence {
val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER)
val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.EVERYONE)
val roomMentionSpan = MentionSpan("#room:domain.org", "#room:domain.org", MentionSpan.Type.ROOM)
return buildSpannedString {
append("Hello ")
inSpans(userMentionSpan) {
@ -163,6 +205,10 @@ class MarkdownTextEditorStateTest {
inSpans(atRoomMentionSpan) {
append("@")
}
append(" and a room ")
inSpans(roomMentionSpan) {
append("#room:domain.org")
}
}
}
}