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..f1f8b844ef 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,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.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.messagecomposer.AttachmentsBottomSheet 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.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.PinnedMessagesBannerView import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults @@ -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/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..8f01f15542 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 @@ -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.preview.error.sendAttachmentError 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.utils.TextPillificationHelper 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.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, @@ -125,6 +126,7 @@ class MessageComposerPresenter @Inject constructor( private val mentionSpanProvider: MentionSpanProvider, private val pillificationHelper: TextPillificationHelper, private val roomMemberProfilesCache: RoomMemberProfilesCache, + private val suggestionsProcessor: SuggestionsProcessor, ) : Presenter { private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) private var pendingEvent: MessageComposerEvents? = null @@ -149,8 +151,10 @@ class MessageComposerPresenter @Inject constructor( } val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) var isMentionsEnabled by remember { mutableStateOf(false) } + var isRoomAliasSuggestionsEnabled by remember { mutableStateOf(false) } LaunchedEffect(Unit) { isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions) + isRoomAliasSuggestionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.RoomAliasSuggestions) } val cameraPermissionState = cameraPermissionPresenter.present() @@ -189,6 +193,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 +218,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 +234,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() - val result = MentionSuggestionsProcessor.process( + suggestions.clear() + val result = suggestionsProcessor.process( suggestion = suggestion, roomMembersState = roomMembersState, + roomAliasSuggestions = if (isRoomAliasSuggestionsEnabled) roomAliasSuggestions else emptyList(), currentUserId = currentUserId, canSendRoomMention = ::canSendRoomMention, ) if (result.isNotEmpty()) { - memberSuggestions.addAll(result) + suggestions.addAll(result) } } .collect() @@ -362,22 +369,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 +429,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 +444,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 +466,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 +639,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 +655,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..860b1147cd --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RoomAliasSuggestionsDataSource.kt @@ -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> +} + +@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/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt similarity index 64% 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/messagecomposer/suggestions/SuggestionsPickerView.kt index 48c626680e..c52ef4771c 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/messagecomposer/suggestions/SuggestionsPickerView.kt @@ -14,7 +14,7 @@ * 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.layout.Arrangement @@ -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,42 @@ 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.matrix.ui.model.getAvatarData +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 +89,33 @@ 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 -> suggestion.roomMember.getAvatarData(avatarSize) + is ResolvedSuggestion.Alias -> suggestion.roomSummary.getAvatarData(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 +147,7 @@ private fun RoomMemberSuggestionItemView( @PreviewsDayNight @Composable -internal fun MentionSuggestionsPickerViewPreview() { +internal fun SuggestionsPickerViewPreview() { ElementPreview { val roomMember = RoomMember( userId = UserId("@alice:server.org"), @@ -155,14 +160,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/mentions/MentionSuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt similarity index 62% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt index f0e89c1148..171f4a2713 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/messagecomposer/suggestions/SuggestionsProcessor.kt @@ -14,63 +14,63 @@ * 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.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 +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 { - // We don't want to retrieve thousands of members - private const val MAX_BATCH_ITEMS = 100 - +class SuggestionsProcessor @Inject constructor() { /** - * Process the mention suggestions. + * Process the suggestion. * @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 + * @return The list of suggestions to display */ suspend fun process( suggestion: Suggestion?, roomMembersState: MatrixRoomMembersState, + roomAliasSuggestions: List, currentUserId: UserId, canSendRoomMention: suspend () -> Boolean, - ): List { - val members = roomMembersState.roomMembers() - return when { - members.isNullOrEmpty() || suggestion == null -> { + ): List { + suggestion ?: return emptyList() + return when (suggestion.type) { + 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 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?, currentUserId: UserId, canSendRoomMention: Boolean, - ): List { + ): List { return if (roomMembers.isNullOrEmpty()) { emptyList() } else { @@ -97,13 +97,18 @@ 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 } } } + + companion object { + // We don't want to retrieve thousands of members + private const val MAX_BATCH_ITEMS = 100 + } } 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..c9700a60bd 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,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.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.messagecomposer.suggestions.SuggestionsProcessor 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 +1010,7 @@ class MessagesPresenterTest { analyticsService = analyticsService, messageComposerContext = DefaultMessageComposerContext(), richTextEditorStateFactory = TestRichTextEditorStateFactory(), + roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(), permissionsPresenterFactory = permissionsPresenterFactory, permalinkParser = FakePermalinkParser(), permalinkBuilder = FakePermalinkBuilder(), @@ -1016,6 +1019,7 @@ class MessagesPresenterTest { mentionSpanProvider = mentionSpanProvider, pillificationHelper = FakeTextPillificationHelper(), roomMemberProfilesCache = RoomMemberProfilesCache(), + suggestionsProcessor = SuggestionsProcessor(), ).apply { showTextFormatting = true isTesting = true diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt new file mode 100644 index 0000000000..4fa976fbb4 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt @@ -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 + ) + ) + ) + } + } +} 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..632f529632 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,7 @@ 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.messagecomposer.suggestions.SuggestionsProcessor 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 @@ -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.permalink.PermalinkBuilder 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.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.draft.ComposerDraft 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.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 +364,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 +416,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 +476,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 +528,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 +970,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 +1035,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 +1054,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 +1064,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 +1102,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 +1119,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 +1137,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 +1502,7 @@ class MessageComposerPresenterTest { analyticsService, DefaultMessageComposerContext(), TestRichTextEditorStateFactory(), + roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permalinkParser = permalinkParser, permalinkBuilder = permalinkBuilder, @@ -1515,6 +1511,7 @@ class MessageComposerPresenterTest { mentionSpanProvider = mentionSpanProvider, pillificationHelper = textPillificationHelper, roomMemberProfilesCache = roomMemberProfilesCache, + suggestionsProcessor = SuggestionsProcessor(), ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled 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/messagecomposer/suggestions/SuggestionsProcessorTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt new file mode 100644 index 0000000000..c3f7806cc4 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt @@ -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), + ) + ) + } +} 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/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index ccc910d02c..93951003f8 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -72,6 +72,13 @@ enum class FeatureFlags( defaultValue = { true }, isFinished = false, ), + RoomAliasSuggestions( + key = "feature.roomAliasSuggestions", + title = "Room alias suggestions", + description = "Type `#` to get room alias suggestions and insert them", + defaultValue = { false }, + isFinished = false, + ), MarkAsUnread( key = "feature.markAsUnread", title = "Mark as unread", 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..af1ad33bdc 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 @@ -31,11 +31,11 @@ import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState -import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomNotificationMode @@ -105,8 +105,8 @@ class FakeMatrixRoom( private val setTopicResult: (String) -> Result = { 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/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt index 40d32878df..1bddedc2e5 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt @@ -24,7 +24,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.asEventId -import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -337,8 +337,8 @@ class NotificationBroadcastReceiverHandlerTest { @Test fun `Test send reply`() = runTest { - val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } - val replyMessage = lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } + val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val replyMessage = lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } val liveTimeline = FakeTimeline().apply { sendMessageLambda = sendMessage replyMessageLambda = replyMessage @@ -363,7 +363,7 @@ class NotificationBroadcastReceiverHandlerTest { runCurrent() sendMessage.assertions() .isCalledOnce() - .with(value(A_MESSAGE), value(null), value(emptyList())) + .with(value(A_MESSAGE), value(null), value(emptyList())) onNotifiableEventReceivedResult.assertions() .isCalledOnce() replyMessage.assertions() @@ -372,7 +372,7 @@ class NotificationBroadcastReceiverHandlerTest { @Test fun `Test send reply blank message`() = runTest { - val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } val liveTimeline = FakeTimeline().apply { sendMessageLambda = sendMessage } @@ -396,8 +396,8 @@ class NotificationBroadcastReceiverHandlerTest { @Test fun `Test send reply to thread`() = runTest { - val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } - val replyMessage = lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } + val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val replyMessage = lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } val liveTimeline = FakeTimeline().apply { sendMessageLambda = sendMessage replyMessageLambda = replyMessage @@ -427,7 +427,7 @@ class NotificationBroadcastReceiverHandlerTest { .isCalledOnce() replyMessage.assertions() .isCalledOnce() - .with(value(A_THREAD_ID.asEventId()), value(A_MESSAGE), value(null), value(emptyList()), value(true)) + .with(value(A_THREAD_ID.asEventId()), value(A_MESSAGE), value(null), value(emptyList()), value(true)) } private fun createIntent( 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..f1437ee1f5 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,10 @@ class MarkdownTextEditorState( text.update(currentText, true) selection = IntRange(end + 1, end + 1) } - is ResolvedMentionSuggestion.Member -> { + is ResolvedSuggestion.Member -> { val currentText = SpannableStringBuilder(text.value()) - val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value - val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return + val text = resolvedSuggestion.roomMember.displayName?.prependIndent("@") ?: resolvedSuggestion.roomMember.userId.value + val link = permalinkBuilder.permalinkForUser(resolvedSuggestion.roomMember.userId).getOrNull() ?: return val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link) currentText.replace(suggestion.start, suggestion.end, "@ ") val end = suggestion.start + 1 @@ -81,6 +82,17 @@ class MarkdownTextEditorState( this.text.update(currentText, true) this.selection = IntRange(end + 1, end + 1) } + is ResolvedSuggestion.Alias -> { + val currentText = SpannableStringBuilder(text.value()) + val text = resolvedSuggestion.roomAlias.value + val link = permalinkBuilder.permalinkForRoomAlias(resolvedSuggestion.roomAlias).getOrNull() ?: return + val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link) + currentText.replace(suggestion.start, suggestion.end, "# ") + val end = suggestion.start + 1 + currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + this.text.update(currentText, true) + this.selection = IntRange(end + 1, end + 1) + } } } @@ -96,14 +108,18 @@ class MarkdownTextEditorState( val end = charSequence.getSpanEnd(mention) when (mention.type) { MentionSpan.Type.USER -> { - val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue - replace(start, end, "[${mention.rawValue}]($link)") + permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull()?.let { link -> + replace(start, end, "[${mention.rawValue}]($link)") + } } MentionSpan.Type.EVERYONE -> { replace(start, end, "@room") } - // Nothing to do here yet - MentionSpan.Type.ROOM -> Unit + MentionSpan.Type.ROOM -> { + permalinkBuilder.permalinkForRoomAlias(RoomAlias(mention.rawValue)).getOrNull()?.let { link -> + replace(start, end, "[${mention.text}]($link)") + } + } } } } @@ -113,13 +129,13 @@ class MarkdownTextEditorState( } } - fun getMentions(): List { + 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..3cd714f950 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 @@ -21,14 +21,17 @@ import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData -import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider -import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType @@ -38,68 +41,102 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class MarkdownTextEditorStateTest { @Test - fun `insertMention - with no currentMentionSuggestion does nothing`() { + fun `insertMention - room alias - getMentions return empty list`() { + val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true) + val suggestion = ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary(canonicalAlias = A_ROOM_ALIAS)) + val permalinkBuilder = FakePermalinkBuilder() + val mentionSpanProvider = aMentionSpanProvider() + state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder) + assertThat(state.getMentions()).isEmpty() + } + + @Test + fun `insertSuggestion - room alias - with member but failed PermalinkBuilder result`() { + val state = MarkdownTextEditorState(initialText = "Hello #", initialFocus = true).apply { + currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Room, text = "") + } + val suggestion = ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary(canonicalAlias = A_ROOM_ALIAS)) + val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) + val permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.failure(IllegalStateException("Failed")) }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder) + } + + @Test + fun `insertSuggestion - room alias`() { + val state = MarkdownTextEditorState(initialText = "Hello #", initialFocus = true).apply { + currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Room, text = "") + } + val suggestion = ResolvedSuggestion.Alias(A_ROOM_ALIAS, aRoomSummary(canonicalAlias = A_ROOM_ALIAS)) + val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) + val permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { Result.success("https://matrix.to/#/${A_ROOM_ALIAS.value}") }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + state.insertSuggestion(suggestion, mentionSpanProvider, permalinkBuilder) + } + + @Test + fun `insertSuggestion - with no currentMentionSuggestion does nothing`() { val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true) val member = aRoomMember() - val mention = ResolvedMentionSuggestion.Member(member) + val mention = ResolvedSuggestion.Member(member) val permalinkBuilder = FakePermalinkBuilder() val mentionSpanProvider = aMentionSpanProvider() - state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) assertThat(state.getMentions()).isEmpty() } @Test - fun `insertMention - with member but failed PermalinkBuilder result`() { + fun `insertSuggestion - with member but failed PermalinkBuilder result`() { val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { - currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") } val member = aRoomMember() - val mention = ResolvedMentionSuggestion.Member(member) + val mention = ResolvedSuggestion.Member(member) val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.failure(IllegalStateException("Failed")) }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) - state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) val mentions = state.getMentions() assertThat(mentions).isEmpty() } @Test - fun `insertMention - with member`() { + fun `insertSuggestion - with member`() { val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { - currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") } val member = aRoomMember() - val mention = ResolvedMentionSuggestion.Member(member) + val mention = ResolvedSuggestion.Member(member) val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/${member.userId}") }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) - state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) val mentions = state.getMentions() assertThat(mentions).isNotEmpty() - assertThat((mentions.firstOrNull() as? Mention.User)?.userId).isEqualTo(member.userId) + assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId).isEqualTo(member.userId) } @Test - fun `insertMention - with @room`() { + fun `insertSuggestion - with @room`() { val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { - currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") } - val mention = ResolvedMentionSuggestion.AtRoom + val mention = ResolvedSuggestion.AtRoom val permalinkBuilder = FakePermalinkBuilder() val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) - state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + state.insertSuggestion(mention, mentionSpanProvider, permalinkBuilder) val mentions = state.getMentions() assertThat(mentions).isNotEmpty() - assertThat(mentions.firstOrNull()).isInstanceOf(Mention.AtRoom::class.java) + assertThat(mentions.firstOrNull()).isInstanceOf(IntentionalMention.Room::class.java) } @Test @@ -115,14 +152,18 @@ class MarkdownTextEditorStateTest { @Test fun `getMessageMarkdown - when there are MentionSpans returns the same text with links to the mentions`() { val text = "No mentions here" - val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("https://matrix.to/#/$it") }) + val permalinkBuilder = FakePermalinkBuilder( + permalinkForUserLambda = { Result.success("https://matrix.to/#/$it") }, + permalinkForRoomAliasLambda = { Result.success("https://matrix.to/#/$it") }, + ) val state = MarkdownTextEditorState(initialText = text, initialFocus = true) state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder) assertThat(markdown).isEqualTo( - "Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room" + "Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room" + + " and a room [#room:domain.org](https://matrix.to/#/#room:domain.org)" ) } @@ -141,8 +182,8 @@ class MarkdownTextEditorStateTest { val mentions = state.getMentions() assertThat(mentions).isNotEmpty() - assertThat((mentions.firstOrNull() as? Mention.User)?.userId?.value).isEqualTo("@alice:matrix.org") - assertThat(mentions.lastOrNull()).isInstanceOf(Mention.AtRoom::class.java) + assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId?.value).isEqualTo("@alice:matrix.org") + assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java) } private fun aMentionSpanProvider( @@ -154,6 +195,7 @@ class MarkdownTextEditorStateTest { private fun aMarkdownTextWithMentions(): CharSequence { val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER) val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.EVERYONE) + val roomMentionSpan = MentionSpan("#room:domain.org", "#room:domain.org", MentionSpan.Type.ROOM) return buildSpannedString { append("Hello ") inSpans(userMentionSpan) { @@ -163,6 +205,10 @@ class MarkdownTextEditorStateTest { inSpans(atRoomMentionSpan) { append("@") } + append(" and a room ") + inSpans(roomMentionSpan) { + append("#room:domain.org") + } } } } diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.mentions_MentionSuggestionsPickerView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.mentions_MentionSuggestionsPickerView_Day_0_en.png deleted file mode 100644 index f9dfbe16d6..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.mentions_MentionSuggestionsPickerView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:585d57c2b8e9a068fcd5e23ceb9e54ea4b87e7ed41035e31e13fe6869a0a6e06 -size 16842 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.mentions_MentionSuggestionsPickerView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.mentions_MentionSuggestionsPickerView_Night_0_en.png deleted file mode 100644 index 90cb82da36..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.mentions_MentionSuggestionsPickerView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e581b1b19e882e164faba8dbfee8b37f91391ebf4bcda69fccb66439586ee24c -size 16910 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Day_0_en.png new file mode 100644 index 0000000000..1d3db4da10 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d27b66d236df0754cc3d138e1c319223feaf1e58e1d0a11841eaec8d33af064f +size 22373 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Night_0_en.png new file mode 100644 index 0000000000..ddbe055b36 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dbb94e7c3af4ca7c1bb635c314d853d2c8575ca691f515b22a29b0ce72444475 +size 22549