diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index df6aa26778..7d883894b0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -66,7 +66,7 @@ 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.model.TimelineItemAction 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.mentions.SuggestionsPickerView 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.MessageComposerEvents @@ -377,7 +377,7 @@ private fun MessagesViewContent( @Composable {} }, 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 } else { RectangleShape @@ -427,7 +427,7 @@ private fun MessagesViewContent( }, sheetContentKey = sheetResizeContentKey.intValue, 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) { Column(modifier = Modifier.fillMaxWidth()) { - MentionSuggestionsPickerView( + SuggestionsPickerView( modifier = Modifier .heightIn(max = 230.dp) // 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, roomName = state.roomName.dataOrNull(), roomAvatarData = state.roomAvatar.dataOrNull(), - memberSuggestions = state.composerState.memberSuggestions, + suggestions = state.composerState.suggestions, onSelectSuggestion = { - state.composerState.eventSink(MessageComposerEvents.InsertMention(it)) + state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it)) } ) MessageComposerView( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt index f0e89c1148..4467481cb5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt @@ -16,13 +16,14 @@ package io.element.android.features.messages.impl.mentions +import io.element.android.features.messages.impl.messagecomposer.RoomAliasSuggestion import io.element.android.libraries.core.data.filterUpTo 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.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipState 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.SuggestionType @@ -37,6 +38,7 @@ object MentionSuggestionsProcessor { * Process the mention suggestions. * @param suggestion The current suggestion input * @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 canSendRoomMention Should return true if the current user can send room mentions * @return The list of mentions to display @@ -44,9 +46,10 @@ object MentionSuggestionsProcessor { suspend fun process( suggestion: Suggestion?, roomMembersState: MatrixRoomMembersState, + roomAliasSuggestions: List, currentUserId: UserId, canSendRoomMention: suspend () -> Boolean, - ): List { + ): List { val members = roomMembersState.roomMembers() return when { members.isNullOrEmpty() || suggestion == null -> { @@ -65,6 +68,11 @@ object MentionSuggestionsProcessor { ) matchingMembers } + SuggestionType.Room -> { + roomAliasSuggestions + .filter { it.roomAlias.value.contains(suggestion.text, ignoreCase = true) } + .map { ResolvedSuggestion.Alias(it.roomAlias, it.roomSummary) } + } else -> { // Clear suggestions emptyList() @@ -79,7 +87,7 @@ object MentionSuggestionsProcessor { roomMembers: List?, currentUserId: UserId, canSendRoomMention: Boolean, - ): List { + ): List { return if (roomMembers.isNullOrEmpty()) { emptyList() } else { @@ -97,10 +105,10 @@ object MentionSuggestionsProcessor { .filterUpTo(MAX_BATCH_ITEMS) { member -> isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query) } - .map(ResolvedMentionSuggestion::Member) + .map(ResolvedSuggestion::Member) if ("room".contains(query) && canSendRoomMention) { - listOf(ResolvedMentionSuggestion.AtRoom) + matchingMembers + listOf(ResolvedSuggestion.AtRoom) + matchingMembers } else { matchingMembers } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/SuggestionsPickerView.kt similarity index 62% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/SuggestionsPickerView.kt index 48c626680e..f9adc75c20 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/SuggestionsPickerView.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -39,38 +40,41 @@ import io.element.android.libraries.designsystem.preview.ElementPreview 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.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.UserId import io.element.android.libraries.matrix.api.room.RoomMember 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.textcomposer.mentions.ResolvedSuggestion import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @Composable -fun MentionSuggestionsPickerView( +fun SuggestionsPickerView( roomId: RoomId, roomName: String?, roomAvatarData: AvatarData?, - memberSuggestions: ImmutableList, - onSelectSuggestion: (ResolvedMentionSuggestion) -> Unit, + suggestions: ImmutableList, + onSelectSuggestion: (ResolvedSuggestion) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( modifier = modifier.fillMaxWidth(), ) { items( - memberSuggestions, + suggestions, key = { suggestion -> when (suggestion) { - is ResolvedMentionSuggestion.AtRoom -> "@room" - is ResolvedMentionSuggestion.Member -> suggestion.roomMember.userId.value + is ResolvedSuggestion.AtRoom -> "@room" + is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value + is ResolvedSuggestion.Alias -> suggestion.roomAlias.value } } ) { Column(modifier = Modifier.fillParentMaxWidth()) { - RoomMemberSuggestionItemView( - memberSuggestion = it, + SuggestionItemView( + suggestion = it, roomId = roomId.value, roomName = roomName, roomAvatar = roomAvatarData, @@ -84,33 +88,44 @@ fun MentionSuggestionsPickerView( } @Composable -private fun RoomMemberSuggestionItemView( - memberSuggestion: ResolvedMentionSuggestion, +private fun SuggestionItemView( + suggestion: ResolvedSuggestion, roomId: String, roomName: String?, roomAvatar: AvatarData?, - onSelectSuggestion: (ResolvedMentionSuggestion) -> Unit, + onSelectSuggestion: (ResolvedSuggestion) -> Unit, modifier: Modifier = Modifier, ) { - Row(modifier = modifier.clickable { onSelectSuggestion(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) { - val avatarData = when (memberSuggestion) { - is ResolvedMentionSuggestion.AtRoom -> roomAvatar?.copy(size = AvatarSize.Suggestion) - ?: AvatarData(roomId, roomName, null, AvatarSize.Suggestion) - is ResolvedMentionSuggestion.Member -> AvatarData( - id = memberSuggestion.roomMember.userId.value, - name = memberSuggestion.roomMember.displayName, - url = memberSuggestion.roomMember.avatarUrl, - size = AvatarSize.Suggestion, + Row( + modifier = modifier.clickable { onSelectSuggestion(suggestion) }, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + val avatarSize = AvatarSize.Suggestion + val avatarData = when (suggestion) { + is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) + is ResolvedSuggestion.Member -> AvatarData( + suggestion.roomMember.userId.value, + suggestion.roomMember.displayName, + suggestion.roomMember.avatarUrl, + avatarSize, + ) + is ResolvedSuggestion.Alias -> AvatarData( + suggestion.roomSummary.roomId.value, + suggestion.roomSummary.name, + suggestion.roomSummary.avatarUrl, + avatarSize, ) } - val title = when (memberSuggestion) { - is ResolvedMentionSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title) - is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.displayName + val title = when (suggestion) { + is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title) + is ResolvedSuggestion.Member -> suggestion.roomMember.displayName + is ResolvedSuggestion.Alias -> suggestion.roomSummary.name } - val subtitle = when (memberSuggestion) { - is ResolvedMentionSuggestion.AtRoom -> "@room" - is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.userId.value + val subtitle = when (suggestion) { + is ResolvedSuggestion.AtRoom -> "@room" + is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value + is ResolvedSuggestion.Alias -> suggestion.roomAlias.value } Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp)) @@ -142,7 +157,7 @@ private fun RoomMemberSuggestionItemView( @PreviewsDayNight @Composable -internal fun MentionSuggestionsPickerViewPreview() { +internal fun SuggestionsPickerViewPreview() { ElementPreview { val roomMember = RoomMember( userId = UserId("@alice:server.org"), @@ -155,14 +170,24 @@ internal fun MentionSuggestionsPickerViewPreview() { isIgnored = false, 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"), roomName = "Room", roomAvatarData = null, - memberSuggestions = persistentListOf( - ResolvedMentionSuggestion.AtRoom, - ResolvedMentionSuggestion.Member(roomMember), - ResolvedMentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), + suggestions = persistentListOf( + ResolvedSuggestion.AtRoom, + ResolvedSuggestion.Member(roomMember), + ResolvedSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), + ResolvedSuggestion.Alias( + anAlias, + roomSummaryDetails, + ) ), onSelectSuggestion = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt index 1f6ae7c7f4..d5f3429450 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt @@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.messagecomposer import android.net.Uri 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.Suggestion @@ -44,6 +44,6 @@ sealed interface MessageComposerEvents { data class Error(val error: Throwable) : MessageComposerEvents data class TypingNotice(val isTyping: Boolean) : 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 } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index d0e50d114c..114a0fdb2a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -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.PermalinkData 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.Mention 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.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.textcomposer.mentions.LocalMentionSpanTheme 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.Message import io.element.android.libraries.textcomposer.model.MessageComposerMode @@ -117,6 +117,7 @@ class MessageComposerPresenter @Inject constructor( private val analyticsService: AnalyticsService, private val messageComposerContext: DefaultMessageComposerContext, private val richTextEditorStateFactory: RichTextEditorStateFactory, + private val roomAliasSuggestionsDataSource: RoomAliasSuggestionsDataSource, private val permalinkParser: PermalinkParser, private val permalinkBuilder: PermalinkBuilder, permissionsPresenterFactory: PermissionsPresenter.Factory, @@ -189,6 +190,8 @@ class MessageComposerPresenter @Inject constructor( val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true) + val roomAliasSuggestions by roomAliasSuggestionsDataSource.getAllRoomAliasSuggestions().collectAsState(initial = emptyList()) + LaunchedEffect(attachmentsState.value) { when (val attachmentStateValue = attachmentsState.value) { is AttachmentsState.Sending.Processing -> { @@ -212,7 +215,7 @@ class MessageComposerPresenter @Inject constructor( } } - val memberSuggestions = remember { mutableStateListOf() } + val suggestions = remember { mutableStateListOf() } LaunchedEffect(isMentionsEnabled) { if (!isMentionsEnabled) return@LaunchedEffect val currentUserId = room.sessionId @@ -228,15 +231,16 @@ class MessageComposerPresenter @Inject constructor( val mentionCompletionTrigger = suggestionSearchTrigger.debounce(0.3.seconds).filter { !it?.text.isNullOrEmpty() } merge(mentionStartTrigger, mentionCompletionTrigger) .combine(room.membersStateFlow) { suggestion, roomMembersState -> - memberSuggestions.clear() + suggestions.clear() val result = MentionSuggestionsProcessor.process( suggestion = suggestion, roomMembersState = roomMembersState, + roomAliasSuggestions = roomAliasSuggestions, currentUserId = currentUserId, canSendRoomMention = ::canSendRoomMention, ) if (result.isNotEmpty()) { - memberSuggestions.addAll(result) + suggestions.addAll(result) } } .collect() @@ -362,22 +366,27 @@ class MessageComposerPresenter @Inject constructor( is MessageComposerEvents.SuggestionReceived -> { suggestionSearchTrigger.value = event.suggestion } - is MessageComposerEvents.InsertMention -> { + is MessageComposerEvents.InsertSuggestion -> { localCoroutineScope.launch { if (showTextFormatting) { - when (val mention = event.mention) { - is ResolvedMentionSuggestion.AtRoom -> { + when (val suggestion = event.resolvedSuggestion) { + is ResolvedSuggestion.AtRoom -> { richTextEditorState.insertAtRoomMentionAtSuggestion() } - is ResolvedMentionSuggestion.Member -> { - val text = mention.roomMember.userId.value - val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch + is ResolvedSuggestion.Member -> { + val text = suggestion.roomMember.userId.value + 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) } } - } else if (markdownTextEditorState.currentMentionSuggestion != null) { - markdownTextEditorState.insertMention( - mention = event.mention, + } else if (markdownTextEditorState.currentSuggestion != null) { + markdownTextEditorState.insertSuggestion( + resolvedSuggestion = event.resolvedSuggestion, mentionSpanProvider = mentionSpanProvider, permalinkBuilder = permalinkBuilder, ) @@ -417,7 +426,7 @@ class MessageComposerPresenter @Inject constructor( canShareLocation = canShareLocation.value, canCreatePoll = canCreatePoll.value, attachmentsState = attachmentsState.value, - memberSuggestions = memberSuggestions.toPersistentList(), + suggestions = suggestions.toPersistentList(), resolveMentionDisplay = resolveMentionDisplay, eventSink = { handleEvents(it) }, ) @@ -432,17 +441,21 @@ class MessageComposerPresenter @Inject constructor( // Reset composer right away resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) 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 -> { val eventId = capturedMode.eventId val transactionId = capturedMode.transactionId timelineController.invokeOnCurrentTimeline { // 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 -> if (cause is TimelineException.EventNotFound && eventId != null) { // 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 +463,7 @@ class MessageComposerPresenter @Inject constructor( is MessageComposerMode.Reply -> { timelineController.invokeOnCurrentTimeline { - replyMessage(capturedMode.eventId, message.markdown, message.html, message.mentions) + replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions) } } } @@ -623,15 +636,15 @@ class MessageComposerPresenter @Inject constructor( ?.let { state -> buildList { if (state.hasAtRoomMention) { - add(Mention.AtRoom) + add(IntentionalMention.Room) } for (userId in state.userIds) { - add(Mention.User(UserId(userId))) + add(IntentionalMention.User(UserId(userId))) } } } .orEmpty() - Message(html = html, markdown = markdown, mentions = mentions) + Message(html = html, markdown = markdown, intentionalMentions = mentions) } else { val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) val mentions = if (withMentions) { @@ -639,7 +652,7 @@ class MessageComposerPresenter @Inject constructor( } else { emptyList() } - Message(html = null, markdown = markdown, mentions = mentions) + Message(html = null, markdown = markdown, intentionalMentions = mentions) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index 332a9e75f8..3698cdedbc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable 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.TextEditorState import io.element.android.wysiwyg.display.TextDisplay @@ -35,7 +35,7 @@ data class MessageComposerState( val canShareLocation: Boolean, val canCreatePoll: Boolean, val attachmentsState: AttachmentsState, - val memberSuggestions: ImmutableList, + val suggestions: ImmutableList, val resolveMentionDisplay: (String, String) -> TextDisplay, val eventSink: (MessageComposerEvents) -> Unit, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index 824f87bb8e..b30074bb78 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.TextEditorState import io.element.android.wysiwyg.display.TextDisplay @@ -41,7 +41,7 @@ fun aMessageComposerState( canShareLocation: Boolean = true, canCreatePoll: Boolean = true, attachmentsState: AttachmentsState = AttachmentsState.None, - memberSuggestions: ImmutableList = persistentListOf(), + suggestions: ImmutableList = persistentListOf(), ) = MessageComposerState( textEditorState = textEditorState, isFullScreen = isFullScreen, @@ -51,7 +51,7 @@ fun aMessageComposerState( canShareLocation = canShareLocation, canCreatePoll = canCreatePoll, attachmentsState = attachmentsState, - memberSuggestions = memberSuggestions, + suggestions = suggestions, resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, eventSink = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RoomAliasSuggestionsDataSource.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RoomAliasSuggestionsDataSource.kt new file mode 100644 index 0000000000..f905c29697 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RoomAliasSuggestionsDataSource.kt @@ -0,0 +1,58 @@ +/* + * 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> +} + +@ContributesBinding(SessionScope::class) +class DefaultRoomAliasSuggestionsDataSource @Inject constructor( + private val roomListService: RoomListService, +) : RoomAliasSuggestionsDataSource { + override fun getAllRoomAliasSuggestions(): Flow> { + return roomListService + .allRooms + .filteredSummaries + .map { roomSummaries -> + roomSummaries + .mapNotNull { roomSummary -> + roomSummary.canonicalAlias?.let { roomAlias -> + RoomAliasSuggestion( + roomAlias = roomAlias, + roomSummary = roomSummary, + ) + } + } + } + } +} + diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index a6c123971b..9bc223c341 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -29,9 +29,10 @@ 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.aTimelineItemsFactory 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.TestRichTextEditorStateFactory 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.TimelineItemIndexer import io.element.android.features.messages.impl.timeline.TimelinePresenter @@ -1008,6 +1009,7 @@ class MessagesPresenterTest { analyticsService = analyticsService, messageComposerContext = DefaultMessageComposerContext(), richTextEditorStateFactory = TestRichTextEditorStateFactory(), + roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(), permissionsPresenterFactory = permissionsPresenterFactory, permalinkParser = FakePermalinkParser(), permalinkBuilder = FakePermalinkBuilder(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/FakeRoomAliasSuggestionsDataSource.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/FakeRoomAliasSuggestionsDataSource.kt new file mode 100644 index 0000000000..b7ee6f27ca --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/FakeRoomAliasSuggestionsDataSource.kt @@ -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 = emptyList() +) : RoomAliasSuggestionsDataSource { + private val roomAliasSuggestions = MutableStateFlow(initialData) + + override fun getAllRoomAliasSuggestions(): Flow> { + return roomAliasSuggestions + } + + fun emitRoomAliasSuggestions(newData: List) { + roomAliasSuggestions.value = newData + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt similarity index 96% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt rename to features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 924616de7a..e38f7e79c4 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -16,7 +16,7 @@ @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 androidx.compose.runtime.remember @@ -29,11 +29,6 @@ import im.vector.app.features.analytics.plan.Composer 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.FakeComposerDraftService -import io.element.android.features.messages.impl.messagecomposer.AttachmentsState -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.utils.FakeTextPillificationHelper import io.element.android.features.messages.impl.utils.TextPillificationHelper @@ -52,7 +47,7 @@ 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.room.MatrixRoom 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.IntentionalMention 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.ComposerDraftType @@ -90,7 +85,7 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenterFac import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore 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.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType @@ -368,7 +363,7 @@ class MessageComposerPresenterTest { @Test fun `present - edit sent message`() = runTest { - val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List -> + val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List -> Result.success(Unit) } val timeline = FakeTimeline().apply { @@ -420,13 +415,13 @@ class MessageComposerPresenterTest { @Test fun `present - edit sent message event not found`() = runTest { - val timelineEditMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List -> + val timelineEditMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List -> Result.failure(TimelineException.EventNotFound) } val timeline = FakeTimeline().apply { this.editMessageLambda = timelineEditMessageLambda } - val roomEditMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List -> + val roomEditMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List -> Result.success(Unit) } val fakeMatrixRoom = FakeMatrixRoom( @@ -480,7 +475,7 @@ class MessageComposerPresenterTest { @Test fun `present - edit not sent message`() = runTest { - val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List -> + val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List -> Result.success(Unit) } val timeline = FakeTimeline().apply { @@ -532,7 +527,7 @@ class MessageComposerPresenterTest { @Test fun `present - reply message`() = runTest { - val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean -> + val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean -> Result.success(Unit) } val timeline = FakeTimeline().apply { @@ -974,34 +969,34 @@ class MessageComposerPresenterTest { // A null suggestion (no suggestion was received) returns nothing 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 initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) - assertThat(awaitItem().memberSuggestions) - .containsExactly(ResolvedMentionSuggestion.AtRoom, ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) + assertThat(awaitItem().suggestions) + .containsExactly(ResolvedSuggestion.AtRoom, ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david)) // A suggestion containing a part of "room" will also return the room mention 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 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 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 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 canUserTriggerRoomNotificationResult = false initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) - assertThat(awaitItem().memberSuggestions) - .containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) + assertThat(awaitItem().suggestions) + .containsExactly(ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david)) } } @@ -1039,13 +1034,12 @@ class MessageComposerPresenterTest { // 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, ""))) skipItems(1) - assertThat(awaitItem().memberSuggestions) - .containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) + assertThat(awaitItem().suggestions) + .containsExactly(ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david)) } } - @Test - fun `present - insertMention for user in rich text editor`() = runTest { + fun `present - InsertSuggestion`() = runTest { val presenter = createPresenter( coroutineScope = this, permalinkBuilder = FakePermalinkBuilder( @@ -1059,7 +1053,7 @@ class MessageComposerPresenterTest { }.test { val initialState = awaitFirstItem() 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()) .isEqualTo("Hey ${A_USER_ID_2.value}") @@ -1069,17 +1063,17 @@ class MessageComposerPresenterTest { @OptIn(ExperimentalCoroutinesApi::class) @Test fun `present - send messages with intentional mentions`() = runTest { - val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean -> + val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean -> Result.success(Unit) } - val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List -> + val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List -> Result.success(Unit) } val timeline = FakeTimeline().apply { this.replyMessageLambda = replyMessageLambda this.editMessageLambda = editMessageLambda } - val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List -> + val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List -> Result.success(Unit) } val room = FakeMatrixRoom( @@ -1107,7 +1101,7 @@ class MessageComposerPresenterTest { advanceUntilIdle() 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 initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode())) @@ -1124,7 +1118,7 @@ class MessageComposerPresenterTest { assert(replyMessageLambda) .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 skipItems(1) @@ -1142,7 +1136,7 @@ class MessageComposerPresenterTest { assert(editMessageLambda) .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) } @@ -1507,6 +1501,7 @@ class MessageComposerPresenterTest { analyticsService, DefaultMessageComposerContext(), TestRichTextEditorStateFactory(), + roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permalinkParser = permalinkParser, permalinkBuilder = permalinkBuilder, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/TestRichTextEditorStateFactory.kt similarity index 86% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt rename to features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/TestRichTextEditorStateFactory.kt index 921a7331fd..df5c1c6183 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/TestRichTextEditorStateFactory.kt @@ -14,10 +14,9 @@ * 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 io.element.android.features.messages.impl.messagecomposer.RichTextEditorStateFactory import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.rememberRichTextEditorState diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt index 887ce88d95..a1b0e73b65 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt @@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.timeline import app.cash.turbine.test 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.Timeline import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -162,10 +162,10 @@ class TimelineControllerTest { @Test fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest { - val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List -> + val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List -> Result.success(Unit) } - val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List -> + val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List -> Result.success(Unit) } val liveTimeline = FakeTimeline(name = "live").apply { diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt index c0ebb4ee16..4fae510f00 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -100,7 +100,7 @@ class SharePresenter @AssistedInject constructor( matrixClient.getRoom(roomId)?.sendMessage( body = text, htmlBody = null, - mentions = emptyList(), + intentionalMentions = emptyList(), )?.isSuccess.orFalse() } .all { it } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt index 5e562f43c5..a7b2bf26cf 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt @@ -25,6 +25,5 @@ interface PermalinkBuilder { } sealed class PermalinkBuilderError : Throwable() { - data object InvalidUserId : PermalinkBuilderError() - data object InvalidRoomAlias : PermalinkBuilderError() + data object InvalidData : PermalinkBuilderError() } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/Mention.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/IntentionalMention.kt similarity index 71% rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/Mention.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/IntentionalMention.kt index a02fedde4b..0931cf0460 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/Mention.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/IntentionalMention.kt @@ -16,12 +16,9 @@ package io.element.android.libraries.matrix.api.room -import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId -sealed interface Mention { - data class User(val userId: UserId) : Mention - data object AtRoom : Mention - data class Room(val roomId: RoomId) : Mention - data class RoomAlias(val roomAlias: RoomAlias?) : Mention +sealed interface IntentionalMention { + data class User(val userId: UserId) : IntentionalMention + data object Room : IntentionalMention } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index a469904aac..b3feb25d5f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -129,9 +129,9 @@ interface MatrixRoom : Closeable { suspend fun userAvatarUrl(userId: UserId): Result - suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result + suspend fun sendMessage(body: String, htmlBody: String?, intentionalMentions: List): Result - suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result + suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, intentionalMentions: List): Result suspend fun sendImage( file: File, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index f42ec5fbe9..9585b2521b 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.poll.PollKind -import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import kotlinx.coroutines.flow.Flow @@ -52,15 +52,24 @@ interface Timeline : AutoCloseable { fun paginationStatus(direction: PaginationDirection): StateFlow val timelineItems: Flow> - suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result + suspend fun sendMessage( + body: String, + htmlBody: String?, + intentionalMentions: List, + ): Result - suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String?, mentions: List): Result + suspend fun editMessage( + originalEventId: EventId?, + transactionId: TransactionId?, + body: String, htmlBody: String?, + intentionalMentions: List, + ): Result suspend fun replyMessage( eventId: EventId, body: String, htmlBody: String?, - mentions: List, + intentionalMentions: List, fromNotification: Boolean = false, ): Result diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt index 30a458b28f..e3a327300f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt @@ -31,7 +31,7 @@ import javax.inject.Inject class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder { override fun permalinkForUser(userId: UserId): Result { if (!MatrixPatterns.isUserId(userId.value)) { - return Result.failure(PermalinkBuilderError.InvalidUserId) + return Result.failure(PermalinkBuilderError.InvalidData) } return runCatching { matrixToUserPermalink(userId.value) @@ -40,7 +40,7 @@ class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder { override fun permalinkForRoomAlias(roomAlias: RoomAlias): Result { if (!MatrixPatterns.isRoomAlias(roomAlias.value)) { - return Result.failure(PermalinkBuilderError.InvalidRoomAlias) + return Result.failure(PermalinkBuilderError.InvalidData) } return runCatching { matrixToRoomAliasPermalink(roomAlias.value) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt index 795ac2e003..85c63aebc1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt @@ -16,11 +16,11 @@ package io.element.android.libraries.matrix.impl.room -import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.api.room.IntentionalMention import org.matrix.rustcomponents.sdk.Mentions -fun List.map(): Mentions { - val hasAtRoom = any { it is Mention.AtRoom } - val userIds = filterIsInstance().map { it.userId.value } - return Mentions(userIds, hasAtRoom) +fun List.map(): Mentions { + val hasRoom = any { it is IntentionalMention.Room } + val userIds = filterIsInstance().map { it.userId.value } + return Mentions(userIds, hasRoom) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 111370dcc4..20dceeeb87 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -33,11 +33,11 @@ import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState -import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.StateEventType @@ -340,16 +340,21 @@ class RustMatrixRoom( } } - override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result = withContext(roomDispatcher) { + override suspend fun editMessage( + eventId: EventId, + body: String, + htmlBody: String?, + intentionalMentions: List + ): Result = withContext(roomDispatcher) { runCatching { - MessageEventContent.from(body, htmlBody, mentions).use { newContent -> + MessageEventContent.from(body, htmlBody, intentionalMentions).use { newContent -> innerRoom.edit(eventId.value, newContent) } } } - override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result { - return liveTimeline.sendMessage(body, htmlBody, mentions) + override suspend fun sendMessage(body: String, htmlBody: String?, intentionalMentions: List): Result { + return liveTimeline.sendMessage(body, htmlBody, intentionalMentions) } override suspend fun leave(): Result = withContext(roomDispatcher) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 87fca3d395..8c774d2d29 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -26,8 +26,8 @@ import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem @@ -263,8 +263,12 @@ class RustTimeline( } } - override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result = withContext(dispatcher) { - MessageEventContent.from(body, htmlBody, mentions).use { content -> + override suspend fun sendMessage( + body: String, + htmlBody: String?, + intentionalMentions: List, + ): Result = withContext(dispatcher) { + MessageEventContent.from(body, htmlBody, intentionalMentions).use { content -> runCatching { inner.send(content) } @@ -284,13 +288,13 @@ class RustTimeline( transactionId: TransactionId?, body: String, htmlBody: String?, - mentions: List, + intentionalMentions: List, ): Result = withContext(dispatcher) { runCatching { getEventTimelineItem(originalEventId, transactionId).use { item -> inner.edit( - newContent = MessageEventContent.from(body, htmlBody, mentions), + newContent = MessageEventContent.from(body, htmlBody, intentionalMentions), item = item, ) } @@ -301,11 +305,11 @@ class RustTimeline( eventId: EventId, body: String, htmlBody: String?, - mentions: List, + intentionalMentions: List, fromNotification: Boolean, ): Result = withContext(dispatcher) { runCatching { - val msg = MessageEventContent.from(body, htmlBody, mentions) + val msg = MessageEventContent.from(body, htmlBody, intentionalMentions) inner.sendReply(msg, eventId.value) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt index e1728bb528..70de27a30d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt @@ -16,7 +16,7 @@ package io.element.android.libraries.matrix.impl.util -import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.impl.room.map import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation import org.matrix.rustcomponents.sdk.messageEventContentFromHtml @@ -26,11 +26,11 @@ import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown * Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions. */ object MessageEventContent { - fun from(body: String, htmlBody: String?, mentions: List): RoomMessageEventContentWithoutRelation { + fun from(body: String, htmlBody: String?, intentionalMentions: List): RoomMessageEventContentWithoutRelation { return if (htmlBody != null) { messageEventContentFromHtml(body, htmlBody) } else { messageEventContentFromMarkdown(body) - }.withMentions(mentions.map()) + }.withMentions(intentionalMentions.map()) } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt index 3510a362ec..5b18c3b175 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt @@ -19,10 +19,11 @@ package io.element.android.libraries.matrix.test.permalink import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.tests.testutils.lambda.lambdaError class FakePermalinkBuilder( - private val permalinkForUserLambda: (UserId) -> Result = { Result.failure(Exception("Not implemented")) }, - private val permalinkForRoomAliasLambda: (RoomAlias) -> Result = { Result.failure(Exception("Not implemented")) }, + private val permalinkForUserLambda: (UserId) -> Result = { lambdaError() }, + private val permalinkForRoomAliasLambda: (RoomAlias) -> Result = { lambdaError() }, ) : PermalinkBuilder { override fun permalinkForUser(userId: UserId): Result { return permalinkForUserLambda(userId) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 1e7eb8c003..afc6d443ed 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -35,7 +35,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState -import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomNotificationMode @@ -105,8 +105,8 @@ class FakeMatrixRoom( private val setTopicResult: (String) -> Result = { lambdaError() }, private val updateAvatarResult: (String, ByteArray) -> Result = { _, _ -> lambdaError() }, private val removeAvatarResult: () -> Result = { lambdaError() }, - private val editMessageLambda: (EventId, String, String?, List) -> Result = { _, _, _, _ -> lambdaError() }, - private val sendMessageResult: (String, String?, List) -> Result = { _, _, _ -> lambdaError() }, + private val editMessageLambda: (EventId, String, String?, List) -> Result = { _, _, _, _ -> lambdaError() }, + private val sendMessageResult: (String, String?, List) -> Result = { _, _, _ -> lambdaError() }, private val updateUserRoleResult: () -> Result = { lambdaError() }, private val toggleReactionResult: (String, EventId) -> Result = { _, _ -> lambdaError() }, private val retrySendMessageResult: (TransactionId) -> Result = { lambdaError() }, @@ -222,12 +222,12 @@ class FakeMatrixRoom( return updateUserRoleResult() } - override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List) = simulateLongTask { - editMessageLambda(eventId, body, htmlBody, mentions) + override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, intentionalMentions: List) = simulateLongTask { + editMessageLambda(eventId, body, htmlBody, intentionalMentions) } - override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List) = simulateLongTask { - sendMessageResult(body, htmlBody, mentions) + override suspend fun sendMessage(body: String, htmlBody: String?, intentionalMentions: List) = simulateLongTask { + sendMessageResult(body, htmlBody, intentionalMentions) } override suspend fun toggleReaction(emoji: String, eventId: EventId): Result { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index ea9b353a67..ea28693872 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.poll.PollKind -import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.ReceiptType @@ -60,7 +60,7 @@ class FakeTimeline( var sendMessageLambda: ( body: String, htmlBody: String?, - mentions: List, + intentionalMentions: List, ) -> Result = { _, _, _ -> Result.success(Unit) } @@ -68,8 +68,8 @@ class FakeTimeline( override suspend fun sendMessage( body: String, htmlBody: String?, - mentions: List, - ): Result = sendMessageLambda(body, htmlBody, mentions) + intentionalMentions: List, + ): Result = sendMessageLambda(body, htmlBody, intentionalMentions) var redactEventLambda: (eventId: EventId?, transactionId: TransactionId?, reason: String?) -> Result = { _, _, _ -> Result.success(true) @@ -86,7 +86,7 @@ class FakeTimeline( transactionId: TransactionId?, body: String, htmlBody: String?, - mentions: List, + intentionalMentions: List, ) -> Result = { _, _, _, _, _ -> Result.success(Unit) } @@ -96,20 +96,20 @@ class FakeTimeline( transactionId: TransactionId?, body: String, htmlBody: String?, - mentions: List, + intentionalMentions: List, ): Result = editMessageLambda( originalEventId, transactionId, body, htmlBody, - mentions + intentionalMentions ) var replyMessageLambda: ( eventId: EventId, body: String, htmlBody: String?, - mentions: List, + intentionalMentions: List, fromNotification: Boolean, ) -> Result = { _, _, _, _, _ -> Result.success(Unit) @@ -119,13 +119,13 @@ class FakeTimeline( eventId: EventId, body: String, htmlBody: String?, - mentions: List, + intentionalMentions: List, fromNotification: Boolean, ): Result = replyMessageLambda( eventId, body, htmlBody, - mentions, + intentionalMentions, fromNotification, ) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt index f5c407101e..1286f3b065 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt @@ -171,14 +171,14 @@ class NotificationBroadcastReceiverHandler @Inject constructor( eventId = threadId.asEventId(), body = message, htmlBody = null, - mentions = emptyList(), + intentionalMentions = emptyList(), fromNotification = true, ) } else { room.liveTimeline.sendMessage( body = message, htmlBody = null, - mentions = emptyList() + intentionalMentions = emptyList() ) }.onFailure { Timber.e(it, "Failed to send smart reply message") diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt index ea31f51585..d93b1f8ab0 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -111,13 +111,13 @@ fun MarkdownTextInput( state.text.update(editable, false) state.lineCount = lineCount - state.currentMentionSuggestion = editable?.checkSuggestionNeeded() - onReceiveSuggestion(state.currentMentionSuggestion) + state.currentSuggestion = editable?.checkSuggestionNeeded() + onReceiveSuggestion(state.currentSuggestion) } onSelectionChangeListener = { selStart, selEnd -> state.selection = selStart..selEnd - state.currentMentionSuggestion = editableText.checkSuggestionNeeded() - onReceiveSuggestion(state.currentMentionSuggestion) + state.currentSuggestion = editableText.checkSuggestionNeeded() + onReceiveSuggestion(state.currentSuggestion) } if (onSelectRichContent != null) { ViewCompat.setOnReceiveContentListener( diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt similarity index 67% rename from libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt rename to libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt index 03bc48f53d..7bb0fa6dea 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt @@ -17,10 +17,13 @@ package io.element.android.libraries.textcomposer.mentions import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.roomlist.RoomSummary @Immutable -sealed interface ResolvedMentionSuggestion { - data object AtRoom : ResolvedMentionSuggestion - data class Member(val roomMember: RoomMember) : ResolvedMentionSuggestion +sealed interface ResolvedSuggestion { + data object AtRoom : ResolvedSuggestion + data class Member(val roomMember: RoomMember) : ResolvedSuggestion + data class Alias(val roomAlias: RoomAlias, val roomSummary: RoomSummary) : ResolvedSuggestion } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt index dd03c66366..f033966cdb 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt @@ -31,13 +31,14 @@ import androidx.compose.runtime.saveable.SaverScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.core.text.getSpans +import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder -import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider -import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.mentions.getMentionSpans import kotlinx.parcelize.Parcelize @@ -51,16 +52,16 @@ class MarkdownTextEditorState( var hasFocus by mutableStateOf(initialFocus) var requestFocusAction by mutableStateOf({}) var lineCount by mutableIntStateOf(1) - var currentMentionSuggestion by mutableStateOf(null) + var currentSuggestion by mutableStateOf(null) - fun insertMention( - mention: ResolvedMentionSuggestion, + fun insertSuggestion( + resolvedSuggestion: ResolvedSuggestion, mentionSpanProvider: MentionSpanProvider, permalinkBuilder: PermalinkBuilder, ) { - val suggestion = currentMentionSuggestion ?: return - when (mention) { - is ResolvedMentionSuggestion.AtRoom -> { + val suggestion = currentSuggestion ?: return + when (resolvedSuggestion) { + is ResolvedSuggestion.AtRoom -> { val currentText = SpannableStringBuilder(text.value()) val replaceText = "@room" val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "") @@ -70,10 +71,21 @@ class MarkdownTextEditorState( text.update(currentText, true) selection = IntRange(end + 1, end + 1) } - is ResolvedMentionSuggestion.Member -> { + is ResolvedSuggestion.Member -> { val currentText = SpannableStringBuilder(text.value()) - val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value - val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return + val text = resolvedSuggestion.roomMember.displayName?.prependIndent("@") ?: resolvedSuggestion.roomMember.userId.value + val link = permalinkBuilder.permalinkForUser(resolvedSuggestion.roomMember.userId).getOrNull() ?: return + val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link) + currentText.replace(suggestion.start, suggestion.end, ". ") + val end = suggestion.start + 1 + currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + this.text.update(currentText, true) + this.selection = IntRange(end + 1, end + 1) + } + is ResolvedSuggestion.Alias -> { + val currentText = SpannableStringBuilder(text.value()) + val text = resolvedSuggestion.roomAlias.value + val link = permalinkBuilder.permalinkForRoomAlias(resolvedSuggestion.roomAlias).getOrNull() ?: return val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link) currentText.replace(suggestion.start, suggestion.end, "@ ") val end = suggestion.start + 1 @@ -96,14 +108,18 @@ class MarkdownTextEditorState( val end = charSequence.getSpanEnd(mention) when (mention.type) { MentionSpan.Type.USER -> { - val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue - replace(start, end, "[${mention.rawValue}]($link)") + permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull()?.let { link -> + replace(start, end, "[${mention.rawValue}]($link)") + } } MentionSpan.Type.EVERYONE -> { replace(start, end, "@room") } - // Nothing to do here yet - MentionSpan.Type.ROOM -> Unit + MentionSpan.Type.ROOM -> { + permalinkBuilder.permalinkForRoomAlias(RoomAlias(mention.rawValue)).getOrNull()?.let { link -> + replace(start, end, "[${mention.text}]($link)") + } + } } } } @@ -113,13 +129,13 @@ class MarkdownTextEditorState( } } - fun getMentions(): List { + fun getMentions(): List { val text = SpannableString(text.value()) val mentionSpans = text.getSpans(0, text.length) return mentionSpans.mapNotNull { mentionSpan -> when (mentionSpan.type) { - MentionSpan.Type.USER -> Mention.User(UserId(mentionSpan.rawValue)) - MentionSpan.Type.EVERYONE -> Mention.AtRoom + MentionSpan.Type.USER -> IntentionalMention.User(UserId(mentionSpan.rawValue)) + MentionSpan.Type.EVERYONE -> IntentionalMention.Room MentionSpan.Type.ROOM -> null } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Message.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Message.kt index 9467cca627..a43f17ed64 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Message.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Message.kt @@ -16,10 +16,10 @@ package io.element.android.libraries.textcomposer.model -import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.api.room.IntentionalMention data class Message( val html: String?, val markdown: String, - val mentions: List, + val intentionalMentions: List, ) diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt index c5949a3a41..e0e83d16db 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -34,7 +34,7 @@ import io.element.android.libraries.textcomposer.components.markdown.MarkdownTex import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider -import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType @@ -157,13 +157,13 @@ class MarkdownTextInputTest { val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) }) val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/$A_SESSION_ID") }) val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true) - state.currentMentionSuggestion = Suggestion(0, 1, SuggestionType.Mention, "") + state.currentSuggestion = Suggestion(0, 1, SuggestionType.Mention, "") rule.setMarkdownTextInput(state = state) var editor: EditText? = null rule.activityRule.scenario.onActivity { editor = it.findEditor() - state.insertMention( - ResolvedMentionSuggestion.Member(roomMember = aRoomMember()), + state.insertSuggestion( + ResolvedSuggestion.Member(roomMember = aRoomMember()), MentionSpanProvider(permalinkParser = permalinkParser), permalinkBuilder, ) diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/IntentionalMentionSpanProviderTest.kt similarity index 98% rename from libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt rename to libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/IntentionalMentionSpanProviderTest.kt index b6522305af..19490d568b 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/IntentionalMentionSpanProviderTest.kt @@ -32,7 +32,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) -class MentionSpanProviderTest { +class IntentionalMentionSpanProviderTest { @JvmField @Rule val warmUpRule = WarmUpRule() diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt index b4f3c143bd..8b0e48a6df 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt @@ -22,13 +22,13 @@ import androidx.core.text.inSpans import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.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.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider -import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType @@ -41,11 +41,11 @@ class MarkdownTextEditorStateTest { fun `insertMention - with no currentMentionSuggestion does nothing`() { val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true) val member = aRoomMember() - val mention = ResolvedMentionSuggestion.Member(member) + val mention = ResolvedSuggestion.Member(member) val permalinkBuilder = FakePermalinkBuilder() val mentionSpanProvider = aMentionSpanProvider() - state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) assertThat(state.getMentions()).isEmpty() } @@ -53,15 +53,15 @@ class MarkdownTextEditorStateTest { @Test fun `insertMention - with member but failed PermalinkBuilder result`() { val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { - currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") } val member = aRoomMember() - val mention = ResolvedMentionSuggestion.Member(member) + val mention = ResolvedSuggestion.Member(member) val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.failure(IllegalStateException("Failed")) }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) - state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) val mentions = state.getMentions() assertThat(mentions).isEmpty() @@ -70,36 +70,36 @@ class MarkdownTextEditorStateTest { @Test fun `insertMention - with member`() { val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { - currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") } val member = aRoomMember() - val mention = ResolvedMentionSuggestion.Member(member) + val mention = ResolvedSuggestion.Member(member) val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/${member.userId}") }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) - state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) val mentions = state.getMentions() assertThat(mentions).isNotEmpty() - assertThat((mentions.firstOrNull() as? Mention.User)?.userId).isEqualTo(member.userId) + assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId).isEqualTo(member.userId) } @Test fun `insertMention - with @room`() { val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { - currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") } - val mention = ResolvedMentionSuggestion.AtRoom + val mention = ResolvedSuggestion.AtRoom val permalinkBuilder = FakePermalinkBuilder() val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) - state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) val mentions = state.getMentions() assertThat(mentions).isNotEmpty() - assertThat(mentions.firstOrNull()).isInstanceOf(Mention.AtRoom::class.java) + assertThat(mentions.firstOrNull()).isInstanceOf(IntentionalMention.Room::class.java) } @Test @@ -115,14 +115,18 @@ class MarkdownTextEditorStateTest { @Test fun `getMessageMarkdown - when there are MentionSpans returns the same text with links to the mentions`() { val text = "No mentions here" - val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/$it") }) + val permalinkBuilder = FakePermalinkBuilder( + permalinkForUserLambda = { Result.success("https://matrix.to/#/$it") }, + permalinkForRoomAliasLambda = { Result.success("https://matrix.to/#/$it") }, + ) val state = MarkdownTextEditorState(initialText = text, initialFocus = true) state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder) assertThat(markdown).isEqualTo( - "Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room" + "Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room" + + " and a room [#room:domain.org](https://matrix.to/#/#room:domain.org)" ) } @@ -141,8 +145,8 @@ class MarkdownTextEditorStateTest { val mentions = state.getMentions() assertThat(mentions).isNotEmpty() - assertThat((mentions.firstOrNull() as? Mention.User)?.userId?.value).isEqualTo("@alice:matrix.org") - assertThat(mentions.lastOrNull()).isInstanceOf(Mention.AtRoom::class.java) + assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId?.value).isEqualTo("@alice:matrix.org") + assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java) } private fun aMentionSpanProvider( @@ -154,6 +158,7 @@ class MarkdownTextEditorStateTest { private fun aMarkdownTextWithMentions(): CharSequence { val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER) val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.EVERYONE) + val roomMentionSpan = MentionSpan("#room:domain.org", "#room:domain.org", MentionSpan.Type.ROOM) return buildSpannedString { append("Hello ") inSpans(userMentionSpan) { @@ -163,6 +168,10 @@ class MarkdownTextEditorStateTest { inSpans(atRoomMentionSpan) { append("@") } + append(" and a room ") + inSpans(roomMentionSpan) { + append("#room:domain.org") + } } } }