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 eb520f5d9a
42 changed files with 814 additions and 274 deletions

View file

@ -66,11 +66,11 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.mentions.MentionSuggestionsPickerView
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsPickerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults
@ -377,7 +377,7 @@ private fun MessagesViewContent(
@Composable {} @Composable {}
}, },
sheetSwipeEnabled = state.composerState.showTextFormatting, sheetSwipeEnabled = state.composerState.showTextFormatting,
sheetShape = if (state.composerState.showTextFormatting || state.composerState.memberSuggestions.isNotEmpty()) { sheetShape = if (state.composerState.showTextFormatting || state.composerState.suggestions.isNotEmpty()) {
MaterialTheme.shapes.large MaterialTheme.shapes.large
} else { } else {
RectangleShape RectangleShape
@ -427,7 +427,7 @@ private fun MessagesViewContent(
}, },
sheetContentKey = sheetResizeContentKey.intValue, sheetContentKey = sheetResizeContentKey.intValue,
sheetTonalElevation = 0.dp, sheetTonalElevation = 0.dp,
sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp, sheetShadowElevation = if (state.composerState.suggestions.isNotEmpty()) 16.dp else 0.dp,
) )
} }
} }
@ -439,7 +439,7 @@ private fun MessagesViewComposerBottomSheetContents(
) { ) {
if (state.userEventPermissions.canSendMessage) { if (state.userEventPermissions.canSendMessage) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
MentionSuggestionsPickerView( SuggestionsPickerView(
modifier = Modifier modifier = Modifier
.heightIn(max = 230.dp) .heightIn(max = 230.dp)
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions // Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
@ -451,9 +451,9 @@ private fun MessagesViewComposerBottomSheetContents(
roomId = state.roomId, roomId = state.roomId,
roomName = state.roomName.dataOrNull(), roomName = state.roomName.dataOrNull(),
roomAvatarData = state.roomAvatar.dataOrNull(), roomAvatarData = state.roomAvatar.dataOrNull(),
memberSuggestions = state.composerState.memberSuggestions, suggestions = state.composerState.suggestions,
onSelectSuggestion = { onSelectSuggestion = {
state.composerState.eventSink(MessageComposerEvents.InsertMention(it)) state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it))
} }
) )
MessageComposerView( MessageComposerView(

View file

@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.messagecomposer
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
@ -44,6 +44,6 @@ sealed interface MessageComposerEvents {
data class Error(val error: Throwable) : MessageComposerEvents data class Error(val error: Throwable) : MessageComposerEvents
data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents
data class InsertMention(val mention: ResolvedMentionSuggestion) : MessageComposerEvents data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvents
data object SaveDraft : MessageComposerEvents data object SaveDraft : MessageComposerEvents
} }

View file

@ -40,7 +40,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.draft.ComposerDraftService import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -55,8 +55,8 @@ 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.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
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.MatrixRoom
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
@ -72,7 +72,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider 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.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
@ -117,6 +117,7 @@ class MessageComposerPresenter @Inject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val messageComposerContext: DefaultMessageComposerContext, private val messageComposerContext: DefaultMessageComposerContext,
private val richTextEditorStateFactory: RichTextEditorStateFactory, private val richTextEditorStateFactory: RichTextEditorStateFactory,
private val roomAliasSuggestionsDataSource: RoomAliasSuggestionsDataSource,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val permalinkBuilder: PermalinkBuilder, private val permalinkBuilder: PermalinkBuilder,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
@ -125,6 +126,7 @@ class MessageComposerPresenter @Inject constructor(
private val mentionSpanProvider: MentionSpanProvider, private val mentionSpanProvider: MentionSpanProvider,
private val pillificationHelper: TextPillificationHelper, private val pillificationHelper: TextPillificationHelper,
private val roomMemberProfilesCache: RoomMemberProfilesCache, private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val suggestionsProcessor: SuggestionsProcessor,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingEvent: MessageComposerEvents? = null private var pendingEvent: MessageComposerEvents? = null
@ -149,8 +151,10 @@ class MessageComposerPresenter @Inject constructor(
} }
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
var isMentionsEnabled by remember { mutableStateOf(false) } var isMentionsEnabled by remember { mutableStateOf(false) }
var isRoomAliasSuggestionsEnabled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions) isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions)
isRoomAliasSuggestionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.RoomAliasSuggestions)
} }
val cameraPermissionState = cameraPermissionPresenter.present() val cameraPermissionState = cameraPermissionPresenter.present()
@ -189,6 +193,8 @@ class MessageComposerPresenter @Inject constructor(
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true) val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
val roomAliasSuggestions by roomAliasSuggestionsDataSource.getAllRoomAliasSuggestions().collectAsState(initial = emptyList())
LaunchedEffect(attachmentsState.value) { LaunchedEffect(attachmentsState.value) {
when (val attachmentStateValue = attachmentsState.value) { when (val attachmentStateValue = attachmentsState.value) {
is AttachmentsState.Sending.Processing -> { is AttachmentsState.Sending.Processing -> {
@ -212,7 +218,7 @@ class MessageComposerPresenter @Inject constructor(
} }
} }
val memberSuggestions = remember { mutableStateListOf<ResolvedMentionSuggestion>() } val suggestions = remember { mutableStateListOf<ResolvedSuggestion>() }
LaunchedEffect(isMentionsEnabled) { LaunchedEffect(isMentionsEnabled) {
if (!isMentionsEnabled) return@LaunchedEffect if (!isMentionsEnabled) return@LaunchedEffect
val currentUserId = room.sessionId val currentUserId = room.sessionId
@ -228,15 +234,16 @@ class MessageComposerPresenter @Inject constructor(
val mentionCompletionTrigger = suggestionSearchTrigger.debounce(0.3.seconds).filter { !it?.text.isNullOrEmpty() } val mentionCompletionTrigger = suggestionSearchTrigger.debounce(0.3.seconds).filter { !it?.text.isNullOrEmpty() }
merge(mentionStartTrigger, mentionCompletionTrigger) merge(mentionStartTrigger, mentionCompletionTrigger)
.combine(room.membersStateFlow) { suggestion, roomMembersState -> .combine(room.membersStateFlow) { suggestion, roomMembersState ->
memberSuggestions.clear() suggestions.clear()
val result = MentionSuggestionsProcessor.process( val result = suggestionsProcessor.process(
suggestion = suggestion, suggestion = suggestion,
roomMembersState = roomMembersState, roomMembersState = roomMembersState,
roomAliasSuggestions = if (isRoomAliasSuggestionsEnabled) roomAliasSuggestions else emptyList(),
currentUserId = currentUserId, currentUserId = currentUserId,
canSendRoomMention = ::canSendRoomMention, canSendRoomMention = ::canSendRoomMention,
) )
if (result.isNotEmpty()) { if (result.isNotEmpty()) {
memberSuggestions.addAll(result) suggestions.addAll(result)
} }
} }
.collect() .collect()
@ -362,22 +369,27 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerEvents.SuggestionReceived -> { is MessageComposerEvents.SuggestionReceived -> {
suggestionSearchTrigger.value = event.suggestion suggestionSearchTrigger.value = event.suggestion
} }
is MessageComposerEvents.InsertMention -> { is MessageComposerEvents.InsertSuggestion -> {
localCoroutineScope.launch { localCoroutineScope.launch {
if (showTextFormatting) { if (showTextFormatting) {
when (val mention = event.mention) { when (val suggestion = event.resolvedSuggestion) {
is ResolvedMentionSuggestion.AtRoom -> { is ResolvedSuggestion.AtRoom -> {
richTextEditorState.insertAtRoomMentionAtSuggestion() richTextEditorState.insertAtRoomMentionAtSuggestion()
} }
is ResolvedMentionSuggestion.Member -> { is ResolvedSuggestion.Member -> {
val text = mention.roomMember.userId.value val text = suggestion.roomMember.userId.value
val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch val link = permalinkBuilder.permalinkForUser(suggestion.roomMember.userId).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
}
is ResolvedSuggestion.Alias -> {
val text = suggestion.roomAlias.value
val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link) richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
} }
} }
} else if (markdownTextEditorState.currentMentionSuggestion != null) { } else if (markdownTextEditorState.currentSuggestion != null) {
markdownTextEditorState.insertMention( markdownTextEditorState.insertSuggestion(
mention = event.mention, resolvedSuggestion = event.resolvedSuggestion,
mentionSpanProvider = mentionSpanProvider, mentionSpanProvider = mentionSpanProvider,
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
) )
@ -417,7 +429,7 @@ class MessageComposerPresenter @Inject constructor(
canShareLocation = canShareLocation.value, canShareLocation = canShareLocation.value,
canCreatePoll = canCreatePoll.value, canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value, attachmentsState = attachmentsState.value,
memberSuggestions = memberSuggestions.toPersistentList(), suggestions = suggestions.toPersistentList(),
resolveMentionDisplay = resolveMentionDisplay, resolveMentionDisplay = resolveMentionDisplay,
eventSink = { handleEvents(it) }, eventSink = { handleEvents(it) },
) )
@ -432,17 +444,21 @@ class MessageComposerPresenter @Inject constructor(
// Reset composer right away // Reset composer right away
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
when (capturedMode) { when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html, mentions = message.mentions) is MessageComposerMode.Normal -> room.sendMessage(
body = message.markdown,
htmlBody = message.html,
intentionalMentions = message.intentionalMentions
)
is MessageComposerMode.Edit -> { is MessageComposerMode.Edit -> {
val eventId = capturedMode.eventId val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId val transactionId = capturedMode.transactionId
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
// First try to edit the message in the current timeline // First try to edit the message in the current timeline
editMessage(eventId, transactionId, message.markdown, message.html, message.mentions) editMessage(eventId, transactionId, message.markdown, message.html, message.intentionalMentions)
.onFailure { cause -> .onFailure { cause ->
if (cause is TimelineException.EventNotFound && eventId != null) { if (cause is TimelineException.EventNotFound && eventId != null) {
// if the event is not found in the timeline, try to edit the message directly // if the event is not found in the timeline, try to edit the message directly
room.editMessage(eventId, message.markdown, message.html, message.mentions) room.editMessage(eventId, message.markdown, message.html, message.intentionalMentions)
} }
} }
} }
@ -450,7 +466,7 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerMode.Reply -> { is MessageComposerMode.Reply -> {
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
replyMessage(capturedMode.eventId, message.markdown, message.html, message.mentions) replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions)
} }
} }
} }
@ -623,15 +639,15 @@ class MessageComposerPresenter @Inject constructor(
?.let { state -> ?.let { state ->
buildList { buildList {
if (state.hasAtRoomMention) { if (state.hasAtRoomMention) {
add(Mention.AtRoom) add(IntentionalMention.Room)
} }
for (userId in state.userIds) { for (userId in state.userIds) {
add(Mention.User(UserId(userId))) add(IntentionalMention.User(UserId(userId)))
} }
} }
} }
.orEmpty() .orEmpty()
Message(html = html, markdown = markdown, mentions = mentions) Message(html = html, markdown = markdown, intentionalMentions = mentions)
} else { } else {
val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
val mentions = if (withMentions) { val mentions = if (withMentions) {
@ -639,7 +655,7 @@ class MessageComposerPresenter @Inject constructor(
} else { } else {
emptyList() emptyList()
} }
Message(html = null, markdown = markdown, mentions = mentions) Message(html = null, markdown = markdown, intentionalMentions = mentions)
} }
} }

View file

@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.wysiwyg.display.TextDisplay import io.element.android.wysiwyg.display.TextDisplay
@ -35,7 +35,7 @@ data class MessageComposerState(
val canShareLocation: Boolean, val canShareLocation: Boolean,
val canCreatePoll: Boolean, val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState, val attachmentsState: AttachmentsState,
val memberSuggestions: ImmutableList<ResolvedMentionSuggestion>, val suggestions: ImmutableList<ResolvedSuggestion>,
val resolveMentionDisplay: (String, String) -> TextDisplay, val resolveMentionDisplay: (String, String) -> TextDisplay,
val eventSink: (MessageComposerEvents) -> Unit, val eventSink: (MessageComposerEvents) -> Unit,
) )

View file

@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.textcomposer.aRichTextEditorState import io.element.android.libraries.textcomposer.aRichTextEditorState
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.wysiwyg.display.TextDisplay import io.element.android.wysiwyg.display.TextDisplay
@ -41,7 +41,7 @@ fun aMessageComposerState(
canShareLocation: Boolean = true, canShareLocation: Boolean = true,
canCreatePoll: Boolean = true, canCreatePoll: Boolean = true,
attachmentsState: AttachmentsState = AttachmentsState.None, attachmentsState: AttachmentsState = AttachmentsState.None,
memberSuggestions: ImmutableList<ResolvedMentionSuggestion> = persistentListOf(), suggestions: ImmutableList<ResolvedSuggestion> = persistentListOf(),
) = MessageComposerState( ) = MessageComposerState(
textEditorState = textEditorState, textEditorState = textEditorState,
isFullScreen = isFullScreen, isFullScreen = isFullScreen,
@ -51,7 +51,7 @@ fun aMessageComposerState(
canShareLocation = canShareLocation, canShareLocation = canShareLocation,
canCreatePoll = canCreatePoll, canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState, attachmentsState = attachmentsState,
memberSuggestions = memberSuggestions, suggestions = suggestions,
resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
eventSink = {}, eventSink = {},
) )

View file

@ -0,0 +1,57 @@
/*
* Copyright (c) 2024 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.messagecomposer
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
data class RoomAliasSuggestion(
val roomAlias: RoomAlias,
val roomSummary: RoomSummary,
)
interface RoomAliasSuggestionsDataSource {
fun getAllRoomAliasSuggestions(): Flow<List<RoomAliasSuggestion>>
}
@ContributesBinding(SessionScope::class)
class DefaultRoomAliasSuggestionsDataSource @Inject constructor(
private val roomListService: RoomListService,
) : RoomAliasSuggestionsDataSource {
override fun getAllRoomAliasSuggestions(): Flow<List<RoomAliasSuggestion>> {
return roomListService
.allRooms
.filteredSummaries
.map { roomSummaries ->
roomSummaries
.mapNotNull { roomSummary ->
roomSummary.canonicalAlias?.let { roomAlias ->
RoomAliasSuggestion(
roomAlias = roomAlias,
roomSummary = roomSummary,
)
}
}
}
}
}

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.messages.impl.mentions package io.element.android.features.messages.impl.messagecomposer.suggestions
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -39,38 +40,42 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.matrix.ui.components.aRoomSummaryDetails
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@Composable @Composable
fun MentionSuggestionsPickerView( fun SuggestionsPickerView(
roomId: RoomId, roomId: RoomId,
roomName: String?, roomName: String?,
roomAvatarData: AvatarData?, roomAvatarData: AvatarData?,
memberSuggestions: ImmutableList<ResolvedMentionSuggestion>, suggestions: ImmutableList<ResolvedSuggestion>,
onSelectSuggestion: (ResolvedMentionSuggestion) -> Unit, onSelectSuggestion: (ResolvedSuggestion) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn( LazyColumn(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
) { ) {
items( items(
memberSuggestions, suggestions,
key = { suggestion -> key = { suggestion ->
when (suggestion) { when (suggestion) {
is ResolvedMentionSuggestion.AtRoom -> "@room" is ResolvedSuggestion.AtRoom -> "@room"
is ResolvedMentionSuggestion.Member -> suggestion.roomMember.userId.value is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
is ResolvedSuggestion.Alias -> suggestion.roomAlias.value
} }
} }
) { ) {
Column(modifier = Modifier.fillParentMaxWidth()) { Column(modifier = Modifier.fillParentMaxWidth()) {
RoomMemberSuggestionItemView( SuggestionItemView(
memberSuggestion = it, suggestion = it,
roomId = roomId.value, roomId = roomId.value,
roomName = roomName, roomName = roomName,
roomAvatar = roomAvatarData, roomAvatar = roomAvatarData,
@ -84,33 +89,33 @@ fun MentionSuggestionsPickerView(
} }
@Composable @Composable
private fun RoomMemberSuggestionItemView( private fun SuggestionItemView(
memberSuggestion: ResolvedMentionSuggestion, suggestion: ResolvedSuggestion,
roomId: String, roomId: String,
roomName: String?, roomName: String?,
roomAvatar: AvatarData?, roomAvatar: AvatarData?,
onSelectSuggestion: (ResolvedMentionSuggestion) -> Unit, onSelectSuggestion: (ResolvedSuggestion) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row(modifier = modifier.clickable { onSelectSuggestion(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(
val avatarData = when (memberSuggestion) { modifier = modifier.clickable { onSelectSuggestion(suggestion) },
is ResolvedMentionSuggestion.AtRoom -> roomAvatar?.copy(size = AvatarSize.Suggestion) horizontalArrangement = Arrangement.spacedBy(16.dp),
?: AvatarData(roomId, roomName, null, AvatarSize.Suggestion) ) {
is ResolvedMentionSuggestion.Member -> AvatarData( val avatarSize = AvatarSize.Suggestion
id = memberSuggestion.roomMember.userId.value, val avatarData = when (suggestion) {
name = memberSuggestion.roomMember.displayName, is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
url = memberSuggestion.roomMember.avatarUrl, is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize)
size = AvatarSize.Suggestion, is ResolvedSuggestion.Alias -> suggestion.roomSummary.getAvatarData(avatarSize)
)
} }
val title = when (memberSuggestion) { val title = when (suggestion) {
is ResolvedMentionSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title) is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.displayName is ResolvedSuggestion.Member -> suggestion.roomMember.displayName
is ResolvedSuggestion.Alias -> suggestion.roomSummary.name
} }
val subtitle = when (suggestion) {
val subtitle = when (memberSuggestion) { is ResolvedSuggestion.AtRoom -> "@room"
is ResolvedMentionSuggestion.AtRoom -> "@room" is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.userId.value is ResolvedSuggestion.Alias -> suggestion.roomAlias.value
} }
Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp)) Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp))
@ -142,7 +147,7 @@ private fun RoomMemberSuggestionItemView(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun MentionSuggestionsPickerViewPreview() { internal fun SuggestionsPickerViewPreview() {
ElementPreview { ElementPreview {
val roomMember = RoomMember( val roomMember = RoomMember(
userId = UserId("@alice:server.org"), userId = UserId("@alice:server.org"),
@ -155,14 +160,24 @@ internal fun MentionSuggestionsPickerViewPreview() {
isIgnored = false, isIgnored = false,
role = RoomMember.Role.USER, role = RoomMember.Role.USER,
) )
MentionSuggestionsPickerView( val anAlias = remember { RoomAlias("#room:domain.org") }
val roomSummaryDetails = remember {
aRoomSummaryDetails(
name = "My room",
)
}
SuggestionsPickerView(
roomId = RoomId("!room:matrix.org"), roomId = RoomId("!room:matrix.org"),
roomName = "Room", roomName = "Room",
roomAvatarData = null, roomAvatarData = null,
memberSuggestions = persistentListOf( suggestions = persistentListOf(
ResolvedMentionSuggestion.AtRoom, ResolvedSuggestion.AtRoom,
ResolvedMentionSuggestion.Member(roomMember), ResolvedSuggestion.Member(roomMember),
ResolvedMentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), ResolvedSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
ResolvedSuggestion.Alias(
anAlias,
roomSummaryDetails,
)
), ),
onSelectSuggestion = {} onSelectSuggestion = {}
) )

View file

@ -14,63 +14,63 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.messages.impl.mentions package io.element.android.features.messages.impl.messagecomposer.suggestions
import io.element.android.features.messages.impl.messagecomposer.RoomAliasSuggestion
import io.element.android.libraries.core.data.filterUpTo import io.element.android.libraries.core.data.filterUpTo
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType import io.element.android.libraries.textcomposer.model.SuggestionType
import javax.inject.Inject
/** /**
* This class is responsible for processing mention suggestions when `@`, `/` or `#` are type in the composer. * This class is responsible for processing suggestions when `@`, `/` or `#` are type in the composer.
*/ */
object MentionSuggestionsProcessor { class SuggestionsProcessor @Inject constructor() {
// We don't want to retrieve thousands of members
private const val MAX_BATCH_ITEMS = 100
/** /**
* Process the mention suggestions. * Process the suggestion.
* @param suggestion The current suggestion input * @param suggestion The current suggestion input
* @param roomMembersState The room members state, it contains the current users in the room * @param roomMembersState The room members state, it contains the current users in the room
* @param roomAliasSuggestions The available room alias suggestions
* @param currentUserId The current user id * @param currentUserId The current user id
* @param canSendRoomMention Should return true if the current user can send room mentions * @param canSendRoomMention Should return true if the current user can send room mentions
* @return The list of mentions to display * @return The list of suggestions to display
*/ */
suspend fun process( suspend fun process(
suggestion: Suggestion?, suggestion: Suggestion?,
roomMembersState: MatrixRoomMembersState, roomMembersState: MatrixRoomMembersState,
roomAliasSuggestions: List<RoomAliasSuggestion>,
currentUserId: UserId, currentUserId: UserId,
canSendRoomMention: suspend () -> Boolean, canSendRoomMention: suspend () -> Boolean,
): List<ResolvedMentionSuggestion> { ): List<ResolvedSuggestion> {
val members = roomMembersState.roomMembers() suggestion ?: return emptyList()
return when { return when (suggestion.type) {
members.isNullOrEmpty() || suggestion == null -> { SuggestionType.Mention -> {
// Replace suggestions
val members = roomMembersState.roomMembers()
val matchingMembers = getMemberSuggestions(
query = suggestion.text,
roomMembers = members,
currentUserId = currentUserId,
canSendRoomMention = canSendRoomMention()
)
matchingMembers
}
SuggestionType.Room -> {
roomAliasSuggestions
.filter { it.roomAlias.value.contains(suggestion.text, ignoreCase = true) }
.map { ResolvedSuggestion.Alias(it.roomAlias, it.roomSummary) }
}
SuggestionType.Command,
is SuggestionType.Custom -> {
// Clear suggestions // Clear suggestions
emptyList() emptyList()
} }
else -> {
when (suggestion.type) {
SuggestionType.Mention -> {
// Replace suggestions
val matchingMembers = getMemberSuggestions(
query = suggestion.text,
roomMembers = members,
currentUserId = currentUserId,
canSendRoomMention = canSendRoomMention()
)
matchingMembers
}
else -> {
// Clear suggestions
emptyList()
}
}
}
} }
} }
@ -79,7 +79,7 @@ object MentionSuggestionsProcessor {
roomMembers: List<RoomMember>?, roomMembers: List<RoomMember>?,
currentUserId: UserId, currentUserId: UserId,
canSendRoomMention: Boolean, canSendRoomMention: Boolean,
): List<ResolvedMentionSuggestion> { ): List<ResolvedSuggestion> {
return if (roomMembers.isNullOrEmpty()) { return if (roomMembers.isNullOrEmpty()) {
emptyList() emptyList()
} else { } else {
@ -97,13 +97,18 @@ object MentionSuggestionsProcessor {
.filterUpTo(MAX_BATCH_ITEMS) { member -> .filterUpTo(MAX_BATCH_ITEMS) { member ->
isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query) isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
} }
.map(ResolvedMentionSuggestion::Member) .map(ResolvedSuggestion::Member)
if ("room".contains(query) && canSendRoomMention) { if ("room".contains(query) && canSendRoomMention) {
listOf(ResolvedMentionSuggestion.AtRoom) + matchingMembers listOf(ResolvedSuggestion.AtRoom) + matchingMembers
} else { } else {
matchingMembers matchingMembers
} }
} }
} }
companion object {
// We don't want to retrieve thousands of members
private const val MAX_BATCH_ITEMS = 100
}
} }

View file

@ -29,9 +29,11 @@ import io.element.android.features.messages.impl.draft.FakeComposerDraftService
import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext
import io.element.android.features.messages.impl.messagecomposer.FakeRoomAliasSuggestionsDataSource
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.TestRichTextEditorStateFactory
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
import io.element.android.features.messages.impl.textcomposer.TestRichTextEditorStateFactory
import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
@ -1008,6 +1010,7 @@ class MessagesPresenterTest {
analyticsService = analyticsService, analyticsService = analyticsService,
messageComposerContext = DefaultMessageComposerContext(), messageComposerContext = DefaultMessageComposerContext(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(), richTextEditorStateFactory = TestRichTextEditorStateFactory(),
roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),
permissionsPresenterFactory = permissionsPresenterFactory, permissionsPresenterFactory = permissionsPresenterFactory,
permalinkParser = FakePermalinkParser(), permalinkParser = FakePermalinkParser(),
permalinkBuilder = FakePermalinkBuilder(), permalinkBuilder = FakePermalinkBuilder(),
@ -1016,6 +1019,7 @@ class MessagesPresenterTest {
mentionSpanProvider = mentionSpanProvider, mentionSpanProvider = mentionSpanProvider,
pillificationHelper = FakeTextPillificationHelper(), pillificationHelper = FakeTextPillificationHelper(),
roomMemberProfilesCache = RoomMemberProfilesCache(), roomMemberProfilesCache = RoomMemberProfilesCache(),
suggestionsProcessor = SuggestionsProcessor(),
).apply { ).apply {
showTextFormatting = true showTextFormatting = true
isTesting = true isTesting = true

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2024 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
*
* https://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.messagecomposer
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultRoomAliasSuggestionsDataSourceTest {
@Test
fun `getAllRoomAliasSuggestions must emit a list of room alias suggestions`() = runTest {
val roomListService = FakeRoomListService()
val sut = DefaultRoomAliasSuggestionsDataSource(
roomListService
)
val aRoomSummaryWithAnAlias = aRoomSummary(
canonicalAlias = A_ROOM_ALIAS
)
sut.getAllRoomAliasSuggestions().test {
assertThat(awaitItem()).isEmpty()
roomListService.postAllRooms(
listOf(
aRoomSummary(roomId = A_ROOM_ID_2, canonicalAlias = null),
aRoomSummaryWithAnAlias,
)
)
assertThat(awaitItem()).isEqualTo(
listOf(
RoomAliasSuggestion(
roomAlias = A_ROOM_ALIAS,
roomSummary = aRoomSummaryWithAnAlias
)
)
)
}
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2024 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.messagecomposer
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeRoomAliasSuggestionsDataSource(
initialData: List<RoomAliasSuggestion> = emptyList()
) : RoomAliasSuggestionsDataSource {
private val roomAliasSuggestions = MutableStateFlow(initialData)
override fun getAllRoomAliasSuggestions(): Flow<List<RoomAliasSuggestion>> {
return roomAliasSuggestions
}
fun emitRoomAliasSuggestions(newData: List<RoomAliasSuggestion>) {
roomAliasSuggestions.value = newData
}
}

View file

@ -16,7 +16,7 @@
@file:OptIn(ExperimentalCoroutinesApi::class) @file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.impl.textcomposer package io.element.android.features.messages.impl.messagecomposer
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -29,11 +29,7 @@ import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.messages.impl.draft.ComposerDraftService import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.draft.FakeComposerDraftService import io.element.android.features.messages.impl.draft.FakeComposerDraftService
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.features.messages.impl.utils.TextPillificationHelper
@ -50,9 +46,9 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
@ -90,7 +86,7 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenterFac
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider 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.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType import io.element.android.libraries.textcomposer.model.SuggestionType
@ -368,7 +364,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - edit sent message`() = runTest { fun `present - edit sent message`() = runTest {
val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<Mention> -> val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<IntentionalMention> ->
Result.success(Unit) Result.success(Unit)
} }
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
@ -420,13 +416,13 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - edit sent message event not found`() = runTest { fun `present - edit sent message event not found`() = runTest {
val timelineEditMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<Mention> -> val timelineEditMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<IntentionalMention> ->
Result.failure<Unit>(TimelineException.EventNotFound) Result.failure<Unit>(TimelineException.EventNotFound)
} }
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
this.editMessageLambda = timelineEditMessageLambda this.editMessageLambda = timelineEditMessageLambda
} }
val roomEditMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<Mention> -> val roomEditMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention> ->
Result.success(Unit) Result.success(Unit)
} }
val fakeMatrixRoom = FakeMatrixRoom( val fakeMatrixRoom = FakeMatrixRoom(
@ -480,7 +476,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - edit not sent message`() = runTest { fun `present - edit not sent message`() = runTest {
val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<Mention> -> val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<IntentionalMention> ->
Result.success(Unit) Result.success(Unit)
} }
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
@ -532,7 +528,7 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - reply message`() = runTest { fun `present - reply message`() = runTest {
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<Mention>, _: Boolean -> val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
Result.success(Unit) Result.success(Unit)
} }
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
@ -974,34 +970,34 @@ class MessageComposerPresenterTest {
// A null suggestion (no suggestion was received) returns nothing // A null suggestion (no suggestion was received) returns nothing
initialState.eventSink(MessageComposerEvents.SuggestionReceived(null)) initialState.eventSink(MessageComposerEvents.SuggestionReceived(null))
assertThat(awaitItem().memberSuggestions).isEmpty() assertThat(awaitItem().suggestions).isEmpty()
// An empty suggestion returns the room and joined members that are not the current user // An empty suggestion returns the room and joined members that are not the current user
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
assertThat(awaitItem().memberSuggestions) assertThat(awaitItem().suggestions)
.containsExactly(ResolvedMentionSuggestion.AtRoom, ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) .containsExactly(ResolvedSuggestion.AtRoom, ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david))
// A suggestion containing a part of "room" will also return the room mention // A suggestion containing a part of "room" will also return the room mention
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo"))) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo")))
assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.AtRoom) assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.AtRoom)
// A non-empty suggestion will return those joined members whose user id matches it // A non-empty suggestion will return those joined members whose user id matches it
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob"))) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob")))
assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(bob)) assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.Member(bob))
// A non-empty suggestion will return those joined members whose display name matches it // A non-empty suggestion will return those joined members whose display name matches it
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave"))) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave")))
assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(david)) assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.Member(david))
// If the suggestion isn't a mention, no suggestions are returned // If the suggestion isn't a mention, no suggestions are returned
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, ""))) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
assertThat(awaitItem().memberSuggestions).isEmpty() assertThat(awaitItem().suggestions).isEmpty()
// If user has no permission to send `@room` mentions, `RoomMemberSuggestion.Room` is not returned // If user has no permission to send `@room` mentions, `RoomMemberSuggestion.Room` is not returned
canUserTriggerRoomNotificationResult = false canUserTriggerRoomNotificationResult = false
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
assertThat(awaitItem().memberSuggestions) assertThat(awaitItem().suggestions)
.containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) .containsExactly(ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david))
} }
} }
@ -1039,13 +1035,12 @@ class MessageComposerPresenterTest {
// An empty suggestion returns the joined members that are not the current user, but not the room // An empty suggestion returns the joined members that are not the current user, but not the room
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
skipItems(1) skipItems(1)
assertThat(awaitItem().memberSuggestions) assertThat(awaitItem().suggestions)
.containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) .containsExactly(ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david))
} }
} }
@Test fun `present - InsertSuggestion`() = runTest {
fun `present - insertMention for user in rich text editor`() = runTest {
val presenter = createPresenter( val presenter = createPresenter(
coroutineScope = this, coroutineScope = this,
permalinkBuilder = FakePermalinkBuilder( permalinkBuilder = FakePermalinkBuilder(
@ -1059,7 +1054,7 @@ class MessageComposerPresenterTest {
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.textEditorState.setHtml("Hey @bo") initialState.textEditorState.setHtml("Hey @bo")
initialState.eventSink(MessageComposerEvents.InsertMention(ResolvedMentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2)))) initialState.eventSink(MessageComposerEvents.InsertSuggestion(ResolvedSuggestion.Member(aRoomMember(userId = A_USER_ID_2))))
assertThat(initialState.textEditorState.messageHtml()) assertThat(initialState.textEditorState.messageHtml())
.isEqualTo("Hey <a href='https://matrix.to/#/${A_USER_ID_2.value}'>${A_USER_ID_2.value}</a>") .isEqualTo("Hey <a href='https://matrix.to/#/${A_USER_ID_2.value}'>${A_USER_ID_2.value}</a>")
@ -1069,17 +1064,17 @@ class MessageComposerPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - send messages with intentional mentions`() = runTest { fun `present - send messages with intentional mentions`() = runTest {
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<Mention>, _: Boolean -> val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
Result.success(Unit) Result.success(Unit)
} }
val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<Mention> -> val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List<IntentionalMention> ->
Result.success(Unit) Result.success(Unit)
} }
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
this.replyMessageLambda = replyMessageLambda this.replyMessageLambda = replyMessageLambda
this.editMessageLambda = editMessageLambda this.editMessageLambda = editMessageLambda
} }
val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<Mention> -> val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention> ->
Result.success(Unit) Result.success(Unit)
} }
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
@ -1107,7 +1102,7 @@ class MessageComposerPresenterTest {
advanceUntilIdle() advanceUntilIdle()
sendMessageResult.assertions().isCalledOnce() sendMessageResult.assertions().isCalledOnce()
.with(value(A_MESSAGE), any(), value(listOf(Mention.User(A_USER_ID)))) .with(value(A_MESSAGE), any(), value(listOf(IntentionalMention.User(A_USER_ID))))
// Check intentional mentions on reply sent // Check intentional mentions on reply sent
initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode())) initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode()))
@ -1124,7 +1119,7 @@ class MessageComposerPresenterTest {
assert(replyMessageLambda) assert(replyMessageLambda)
.isCalledOnce() .isCalledOnce()
.with(any(), any(), any(), value(listOf(Mention.User(A_USER_ID_2))), value(false)) .with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_2))), value(false))
// Check intentional mentions on edit message // Check intentional mentions on edit message
skipItems(1) skipItems(1)
@ -1142,7 +1137,7 @@ class MessageComposerPresenterTest {
assert(editMessageLambda) assert(editMessageLambda)
.isCalledOnce() .isCalledOnce()
.with(any(), any(), any(), any(), value(listOf(Mention.User(A_USER_ID_3)))) .with(any(), any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_3))))
skipItems(1) skipItems(1)
} }
@ -1507,6 +1502,7 @@ class MessageComposerPresenterTest {
analyticsService, analyticsService,
DefaultMessageComposerContext(), DefaultMessageComposerContext(),
TestRichTextEditorStateFactory(), TestRichTextEditorStateFactory(),
roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
@ -1515,6 +1511,7 @@ class MessageComposerPresenterTest {
mentionSpanProvider = mentionSpanProvider, mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper, pillificationHelper = textPillificationHelper,
roomMemberProfilesCache = roomMemberProfilesCache, roomMemberProfilesCache = roomMemberProfilesCache,
suggestionsProcessor = SuggestionsProcessor(),
).apply { ).apply {
isTesting = true isTesting = true
showTextFormatting = isRichTextEditorEnabled showTextFormatting = isRichTextEditorEnabled

View file

@ -14,10 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.messages.impl.textcomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.element.android.features.messages.impl.messagecomposer.RichTextEditorStateFactory
import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.wysiwyg.compose.rememberRichTextEditorState import io.element.android.wysiwyg.compose.rememberRichTextEditorState

View file

@ -0,0 +1,270 @@
/*
* Copyright (c) 2024 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
*
* https://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.messagecomposer.suggestions
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.messagecomposer.RoomAliasSuggestion
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
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.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SuggestionsProcessorTest {
private fun aMentionSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Mention, text)
private fun aRoomSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Room, text)
private val aCommandSuggestion = Suggestion(0, 1, SuggestionType.Command, "")
private val aCustomSuggestion = Suggestion(0, 1, SuggestionType.Custom("*"), "")
private val suggestionsProcessor = SuggestionsProcessor()
@Test
fun `processing null suggestion will return empty suggestion`() = runTest {
val result = suggestionsProcessor.process(
suggestion = null,
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember())),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)
assertThat(result).isEmpty()
}
@Test
fun `processing Command will return empty suggestion`() = runTest {
val result = suggestionsProcessor.process(
suggestion = aCommandSuggestion,
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember())),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)
assertThat(result).isEmpty()
}
@Test
fun `processing Custom will return empty suggestion`() = runTest {
val result = suggestionsProcessor.process(
suggestion = aCustomSuggestion,
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember())),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)
assertThat(result).isEmpty()
}
@Test
fun `processing Mention suggestion with not loaded members will return empty suggestion`() = runTest {
val result = suggestionsProcessor.process(
suggestion = aMentionSuggestion(""),
roomMembersState = MatrixRoomMembersState.Unknown,
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)
assertThat(result).isEmpty()
}
@Test
fun `processing Mention suggestion with no members will return empty suggestion`() = runTest {
val result = suggestionsProcessor.process(
suggestion = aMentionSuggestion(""),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)
assertThat(result).isEmpty()
}
@Test
fun `processing Room suggestion with no aliases will return empty suggestion`() = runTest {
val result = suggestionsProcessor.process(
suggestion = aRoomSuggestion(""),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)
assertThat(result).isEmpty()
}
@Test
fun `processing Room suggestion with aliases ignoring cases will return a suggestion`() = runTest {
val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS)
val result = suggestionsProcessor.process(
suggestion = aRoomSuggestion("ALI"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
roomAliasSuggestions = listOf(RoomAliasSuggestion(A_ROOM_ALIAS, aRoomSummary)),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)
assertThat(result).isEqualTo(
listOf(
ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary)
)
)
}
@Test
fun `processing Room suggestion with aliases will return a suggestion`() = runTest {
val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS)
val result = suggestionsProcessor.process(
suggestion = aRoomSuggestion("ali"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
roomAliasSuggestions = listOf(RoomAliasSuggestion(A_ROOM_ALIAS, aRoomSummary)),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)
assertThat(result).isEqualTo(
listOf(
ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary)
)
)
}
@Test
fun `processing Room suggestion with aliases not found will return no suggestions`() = runTest {
val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS)
val result = suggestionsProcessor.process(
suggestion = aRoomSuggestion("tot"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()),
roomAliasSuggestions = listOf(RoomAliasSuggestion(A_ROOM_ALIAS, aRoomSummary)),
currentUserId = A_USER_ID,
canSendRoomMention = { true },
)
assertThat(result).isEmpty()
}
@Test
fun `processing Mention suggestion with return matching matrix Id`() = runTest {
val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = null)
val result = suggestionsProcessor.process(
suggestion = aMentionSuggestion("ali"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { true },
)
assertThat(result).isEqualTo(
listOf(
ResolvedSuggestion.Member(aRoomMember)
)
)
}
@Test
fun `processing Mention suggestion with not return the current user`() = runTest {
val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = null)
val result = suggestionsProcessor.process(
suggestion = aMentionSuggestion("ali"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
roomAliasSuggestions = emptyList(),
currentUserId = UserId("@alice:server.org"),
canSendRoomMention = { true },
)
assertThat(result).isEmpty()
}
@Test
fun `processing Mention suggestion with return empty list if there is no matches`() = runTest {
val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "alice")
val result = suggestionsProcessor.process(
suggestion = aMentionSuggestion("bo"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { true },
)
assertThat(result).isEmpty()
}
@Test
fun `processing Mention suggestion with not return not joined member`() = runTest {
val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), membership = RoomMembershipState.INVITE)
val result = suggestionsProcessor.process(
suggestion = aMentionSuggestion("ali"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { true },
)
assertThat(result).isEmpty()
}
@Test
fun `processing Mention suggestion with return matching display name`() = runTest {
val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "bob")
val result = suggestionsProcessor.process(
suggestion = aMentionSuggestion("bo"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { true },
)
assertThat(result).isEqualTo(
listOf(
ResolvedSuggestion.Member(aRoomMember)
)
)
}
@Test
fun `processing Mention suggestion with return matching display name and room if allowed`() = runTest {
val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "ro")
val result = suggestionsProcessor.process(
suggestion = aMentionSuggestion("ro"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { true },
)
assertThat(result).isEqualTo(
listOf(
ResolvedSuggestion.AtRoom,
ResolvedSuggestion.Member(aRoomMember),
)
)
}
@Test
fun `processing Mention suggestion with return matching display name but not room if not allowed`() = runTest {
val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "ro")
val result = suggestionsProcessor.process(
suggestion = aMentionSuggestion("ro"),
roomMembersState = MatrixRoomMembersState.Ready(persistentListOf(aRoomMember)),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { false },
)
assertThat(result).isEqualTo(
listOf(
ResolvedSuggestion.Member(aRoomMember),
)
)
}
}

View file

@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.timeline
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
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.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -162,10 +162,10 @@ class TimelineControllerTest {
@Test @Test
fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest { fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest {
val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List<Mention> -> val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention> ->
Result.success(Unit) Result.success(Unit)
} }
val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List<Mention> -> val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List<IntentionalMention> ->
Result.success(Unit) Result.success(Unit)
} }
val liveTimeline = FakeTimeline(name = "live").apply { val liveTimeline = FakeTimeline(name = "live").apply {

View file

@ -100,7 +100,7 @@ class SharePresenter @AssistedInject constructor(
matrixClient.getRoom(roomId)?.sendMessage( matrixClient.getRoom(roomId)?.sendMessage(
body = text, body = text,
htmlBody = null, htmlBody = null,
mentions = emptyList(), intentionalMentions = emptyList(),
)?.isSuccess.orFalse() )?.isSuccess.orFalse()
} }
.all { it } .all { it }

View file

@ -72,6 +72,13 @@ enum class FeatureFlags(
defaultValue = { true }, defaultValue = { true },
isFinished = false, 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( MarkAsUnread(
key = "feature.markAsUnread", key = "feature.markAsUnread",
title = "Mark as unread", title = "Mark as unread",

View file

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

View file

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

View file

@ -129,9 +129,9 @@ interface MatrixRoom : Closeable {
suspend fun userAvatarUrl(userId: UserId): Result<String?> 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( suspend fun sendImage(
file: File, 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.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo 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.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.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -52,15 +52,24 @@ interface Timeline : AutoCloseable {
fun paginationStatus(direction: PaginationDirection): StateFlow<PaginationStatus> fun paginationStatus(direction: PaginationDirection): StateFlow<PaginationStatus>
val timelineItems: Flow<List<MatrixTimelineItem>> 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( suspend fun replyMessage(
eventId: EventId, eventId: EventId,
body: String, body: String,
htmlBody: String?, htmlBody: String?,
mentions: List<Mention>, intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean = false, fromNotification: Boolean = false,
): Result<Unit> ): Result<Unit>

View file

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

View file

@ -16,11 +16,11 @@
package io.element.android.libraries.matrix.impl.room 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 import org.matrix.rustcomponents.sdk.Mentions
fun List<Mention>.map(): Mentions { fun List<IntentionalMention>.map(): Mentions {
val hasAtRoom = any { it is Mention.AtRoom } val hasRoom = any { it is IntentionalMention.Room }
val userIds = filterIsInstance<Mention.User>().map { it.userId.value } val userIds = filterIsInstance<IntentionalMention.User>().map { it.userId.value }
return Mentions(userIds, hasAtRoom) 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.media.VideoInfo
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService 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.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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo 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.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState 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.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType 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 { runCatching {
MessageEventContent.from(body, htmlBody, mentions).use { newContent -> MessageEventContent.from(body, htmlBody, intentionalMentions).use { newContent ->
innerRoom.edit(eventId.value, newContent) innerRoom.edit(eventId.value, newContent)
} }
} }
} }
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> { override suspend fun sendMessage(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): Result<Unit> {
return liveTimeline.sendMessage(body, htmlBody, mentions) return liveTimeline.sendMessage(body, htmlBody, intentionalMentions)
} }
override suspend fun leave(): Result<Unit> = withContext(roomDispatcher) { 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.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo 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.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.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.isDm
import io.element.android.libraries.matrix.api.room.location.AssetType 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.MatrixTimelineItem
@ -263,8 +263,12 @@ class RustTimeline(
} }
} }
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(dispatcher) { override suspend fun sendMessage(
MessageEventContent.from(body, htmlBody, mentions).use { content -> body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
): Result<Unit> = withContext(dispatcher) {
MessageEventContent.from(body, htmlBody, intentionalMentions).use { content ->
runCatching<Unit> { runCatching<Unit> {
inner.send(content) inner.send(content)
} }
@ -284,13 +288,13 @@ class RustTimeline(
transactionId: TransactionId?, transactionId: TransactionId?,
body: String, body: String,
htmlBody: String?, htmlBody: String?,
mentions: List<Mention>, intentionalMentions: List<IntentionalMention>,
): Result<Unit> = ): Result<Unit> =
withContext(dispatcher) { withContext(dispatcher) {
runCatching<Unit> { runCatching<Unit> {
getEventTimelineItem(originalEventId, transactionId).use { item -> getEventTimelineItem(originalEventId, transactionId).use { item ->
inner.edit( inner.edit(
newContent = MessageEventContent.from(body, htmlBody, mentions), newContent = MessageEventContent.from(body, htmlBody, intentionalMentions),
item = item, item = item,
) )
} }
@ -301,11 +305,11 @@ class RustTimeline(
eventId: EventId, eventId: EventId,
body: String, body: String,
htmlBody: String?, htmlBody: String?,
mentions: List<Mention>, intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean, fromNotification: Boolean,
): Result<Unit> = withContext(dispatcher) { ): Result<Unit> = withContext(dispatcher) {
runCatching { runCatching {
val msg = MessageEventContent.from(body, htmlBody, mentions) val msg = MessageEventContent.from(body, htmlBody, intentionalMentions)
inner.sendReply(msg, eventId.value) inner.sendReply(msg, eventId.value)
} }
} }

View file

@ -16,7 +16,7 @@
package io.element.android.libraries.matrix.impl.util 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 io.element.android.libraries.matrix.impl.room.map
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml 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. * Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions.
*/ */
object MessageEventContent { 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) { return if (htmlBody != null) {
messageEventContentFromHtml(body, htmlBody) messageEventContentFromHtml(body, htmlBody)
} else { } else {
messageEventContentFromMarkdown(body) 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.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId 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.permalink.PermalinkBuilder
import io.element.android.tests.testutils.lambda.lambdaError
class FakePermalinkBuilder( class FakePermalinkBuilder(
private val permalinkForUserLambda: (UserId) -> Result<String> = { Result.failure(Exception("Not implemented")) }, private val permalinkForUserLambda: (UserId) -> Result<String> = { lambdaError() },
private val permalinkForRoomAliasLambda: (RoomAlias) -> Result<String> = { Result.failure(Exception("Not implemented")) }, private val permalinkForRoomAliasLambda: (RoomAlias) -> Result<String> = { lambdaError() },
) : PermalinkBuilder { ) : PermalinkBuilder {
override fun permalinkForUser(userId: UserId): Result<String> { override fun permalinkForUser(userId: UserId): Result<String> {
return permalinkForUserLambda(userId) 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.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.poll.PollKind 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.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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo 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.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState 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.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@ -105,8 +105,8 @@ class FakeMatrixRoom(
private val setTopicResult: (String) -> Result<Unit> = { lambdaError() }, private val setTopicResult: (String) -> Result<Unit> = { lambdaError() },
private val updateAvatarResult: (String, ByteArray) -> Result<Unit> = { _, _ -> lambdaError() }, private val updateAvatarResult: (String, ByteArray) -> Result<Unit> = { _, _ -> lambdaError() },
private val removeAvatarResult: () -> Result<Unit> = { lambdaError() }, private val removeAvatarResult: () -> Result<Unit> = { lambdaError() },
private val editMessageLambda: (EventId, String, String?, List<Mention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() }, private val editMessageLambda: (EventId, String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() },
private val sendMessageResult: (String, String?, List<Mention>) -> Result<Unit> = { _, _, _ -> lambdaError() }, private val sendMessageResult: (String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _ -> lambdaError() },
private val updateUserRoleResult: () -> Result<Unit> = { lambdaError() }, private val updateUserRoleResult: () -> Result<Unit> = { lambdaError() },
private val toggleReactionResult: (String, EventId) -> Result<Unit> = { _, _ -> lambdaError() }, private val toggleReactionResult: (String, EventId) -> Result<Unit> = { _, _ -> lambdaError() },
private val retrySendMessageResult: (TransactionId) -> Result<Unit> = { lambdaError() }, private val retrySendMessageResult: (TransactionId) -> Result<Unit> = { lambdaError() },
@ -222,12 +222,12 @@ class FakeMatrixRoom(
return updateUserRoleResult() return updateUserRoleResult()
} }
override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>) = simulateLongTask { override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>) = simulateLongTask {
editMessageLambda(eventId, body, htmlBody, mentions) editMessageLambda(eventId, body, htmlBody, intentionalMentions)
} }
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>) = simulateLongTask { override suspend fun sendMessage(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>) = simulateLongTask {
sendMessageResult(body, htmlBody, mentions) sendMessageResult(body, htmlBody, intentionalMentions)
} }
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> { 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.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo 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.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.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
@ -60,7 +60,7 @@ class FakeTimeline(
var sendMessageLambda: ( var sendMessageLambda: (
body: String, body: String,
htmlBody: String?, htmlBody: String?,
mentions: List<Mention>, intentionalMentions: List<IntentionalMention>,
) -> Result<Unit> = { _, _, _ -> ) -> Result<Unit> = { _, _, _ ->
Result.success(Unit) Result.success(Unit)
} }
@ -68,8 +68,8 @@ class FakeTimeline(
override suspend fun sendMessage( override suspend fun sendMessage(
body: String, body: String,
htmlBody: String?, htmlBody: String?,
mentions: List<Mention>, intentionalMentions: List<IntentionalMention>,
): Result<Unit> = sendMessageLambda(body, htmlBody, mentions) ): Result<Unit> = sendMessageLambda(body, htmlBody, intentionalMentions)
var redactEventLambda: (eventId: EventId?, transactionId: TransactionId?, reason: String?) -> Result<Boolean> = { _, _, _ -> var redactEventLambda: (eventId: EventId?, transactionId: TransactionId?, reason: String?) -> Result<Boolean> = { _, _, _ ->
Result.success(true) Result.success(true)
@ -86,7 +86,7 @@ class FakeTimeline(
transactionId: TransactionId?, transactionId: TransactionId?,
body: String, body: String,
htmlBody: String?, htmlBody: String?,
mentions: List<Mention>, intentionalMentions: List<IntentionalMention>,
) -> Result<Unit> = { _, _, _, _, _ -> ) -> Result<Unit> = { _, _, _, _, _ ->
Result.success(Unit) Result.success(Unit)
} }
@ -96,20 +96,20 @@ class FakeTimeline(
transactionId: TransactionId?, transactionId: TransactionId?,
body: String, body: String,
htmlBody: String?, htmlBody: String?,
mentions: List<Mention>, intentionalMentions: List<IntentionalMention>,
): Result<Unit> = editMessageLambda( ): Result<Unit> = editMessageLambda(
originalEventId, originalEventId,
transactionId, transactionId,
body, body,
htmlBody, htmlBody,
mentions intentionalMentions
) )
var replyMessageLambda: ( var replyMessageLambda: (
eventId: EventId, eventId: EventId,
body: String, body: String,
htmlBody: String?, htmlBody: String?,
mentions: List<Mention>, intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean, fromNotification: Boolean,
) -> Result<Unit> = { _, _, _, _, _ -> ) -> Result<Unit> = { _, _, _, _, _ ->
Result.success(Unit) Result.success(Unit)
@ -119,13 +119,13 @@ class FakeTimeline(
eventId: EventId, eventId: EventId,
body: String, body: String,
htmlBody: String?, htmlBody: String?,
mentions: List<Mention>, intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean, fromNotification: Boolean,
): Result<Unit> = replyMessageLambda( ): Result<Unit> = replyMessageLambda(
eventId, eventId,
body, body,
htmlBody, htmlBody,
mentions, intentionalMentions,
fromNotification, fromNotification,
) )

View file

@ -171,14 +171,14 @@ class NotificationBroadcastReceiverHandler @Inject constructor(
eventId = threadId.asEventId(), eventId = threadId.asEventId(),
body = message, body = message,
htmlBody = null, htmlBody = null,
mentions = emptyList(), intentionalMentions = emptyList(),
fromNotification = true, fromNotification = true,
) )
} else { } else {
room.liveTimeline.sendMessage( room.liveTimeline.sendMessage(
body = message, body = message,
htmlBody = null, htmlBody = null,
mentions = emptyList() intentionalMentions = emptyList()
) )
}.onFailure { }.onFailure {
Timber.e(it, "Failed to send smart reply message") 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.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId 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.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.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -337,8 +337,8 @@ class NotificationBroadcastReceiverHandlerTest {
@Test @Test
fun `Test send reply`() = runTest { fun `Test send reply`() = 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 replyMessage = lambdaRecorder<EventId, String, String?, List<Mention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) } val replyMessage = lambdaRecorder<EventId, String, String?, List<IntentionalMention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply { val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage sendMessageLambda = sendMessage
replyMessageLambda = replyMessage replyMessageLambda = replyMessage
@ -363,7 +363,7 @@ class NotificationBroadcastReceiverHandlerTest {
runCurrent() runCurrent()
sendMessage.assertions() sendMessage.assertions()
.isCalledOnce() .isCalledOnce()
.with(value(A_MESSAGE), value(null), value(emptyList<Mention>())) .with(value(A_MESSAGE), value(null), value(emptyList<IntentionalMention>()))
onNotifiableEventReceivedResult.assertions() onNotifiableEventReceivedResult.assertions()
.isCalledOnce() .isCalledOnce()
replyMessage.assertions() replyMessage.assertions()
@ -372,7 +372,7 @@ class NotificationBroadcastReceiverHandlerTest {
@Test @Test
fun `Test send reply blank message`() = runTest { 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 { val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage sendMessageLambda = sendMessage
} }
@ -396,8 +396,8 @@ class NotificationBroadcastReceiverHandlerTest {
@Test @Test
fun `Test send reply to thread`() = runTest { fun `Test send reply to thread`() = 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 replyMessage = lambdaRecorder<EventId, String, String?, List<Mention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) } val replyMessage = lambdaRecorder<EventId, String, String?, List<IntentionalMention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply { val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage sendMessageLambda = sendMessage
replyMessageLambda = replyMessage replyMessageLambda = replyMessage
@ -427,7 +427,7 @@ class NotificationBroadcastReceiverHandlerTest {
.isCalledOnce() .isCalledOnce()
replyMessage.assertions() replyMessage.assertions()
.isCalledOnce() .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( private fun createIntent(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:585d57c2b8e9a068fcd5e23ceb9e54ea4b87e7ed41035e31e13fe6869a0a6e06
size 16842

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e581b1b19e882e164faba8dbfee8b37f91391ebf4bcda69fccb66439586ee24c
size 16910

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d27b66d236df0754cc3d138e1c319223feaf1e58e1d0a11841eaec8d33af064f
size 22373

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dbb94e7c3af4ca7c1bb635c314d853d2c8575ca691f515b22a29b0ce72444475
size 22549