From 9d36c8c4ed021e3da6f2af0f7dbfabff81d75d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 20 Aug 2026 13:53:49 +0200 Subject: [PATCH 01/17] Export missing `MessageSearchService` to `SessionScope` --- .../android/libraries/matrix/impl/di/SessionMatrixModule.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index 569714181b..f6ea8019e3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.scanner.ContentScanner +import io.element.android.libraries.matrix.api.search.MessageSearchService import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.verification.SessionVerificationService @@ -114,4 +115,9 @@ object SessionMatrixModule { fun providesGetUrlResolverClient(matrixClient: MatrixClient): ClientUrlContentFetcher { return matrixClient } + + @Provides + fun providesMessageSearchService(matrixClient: MatrixClient): MessageSearchService { + return matrixClient.messageSearchService + } } From be2909bca2b6538a84da3ba6c98a30e900863d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 20 Aug 2026 13:55:15 +0200 Subject: [PATCH 02/17] Add a global search screen, which reads from both the `RoomListSearchDataSource` for the rooms search and `MessageSearchService` for the message search Note the designs and texts of this screen are temporary. --- .../home/impl/search/GlobalSearchEvent.kt | 18 + .../home/impl/search/GlobalSearchPresenter.kt | 283 +++++++++ .../home/impl/search/GlobalSearchState.kt | 92 +++ .../impl/search/GlobalSearchStateProvider.kt | 242 ++++++++ .../home/impl/search/GlobalSearchView.kt | 564 ++++++++++++++++++ .../impl/src/main/res/values/temporary.xml | 17 + .../components/avatar/AvatarData.kt | 2 + .../components/avatar/AvatarSize.kt | 3 + 8 files changed, 1221 insertions(+) create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt create mode 100644 features/home/impl/src/main/res/values/temporary.xml diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt new file mode 100644 index 0000000000..76d9147e94 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface GlobalSearchEvent { + object ClearQuery : GlobalSearchEvent + object ToggleSearchVisibility : GlobalSearchEvent + data class UpdateVisibleRange(val range: IntRange) : GlobalSearchEvent + data class UpdateTarget(val target: GlobalSearchTarget) : GlobalSearchEvent +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt new file mode 100644 index 0000000000..11de6e7c80 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.roomlist.LatestEventValue +import io.element.android.libraries.matrix.api.search.MessageSearch +import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState +import io.element.android.libraries.matrix.api.search.MessageSearchResult +import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageTypeWithAttachment +import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.matrix.ui.messages.toPlainText +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.jvm.optionals.getOrElse +import kotlin.time.Duration.Companion.milliseconds + +@AssistedInject +class GlobalSearchPresenter( + @Assisted private val coroutineScope: CoroutineScope, + private val roomListSearchDataSourceFactory: RoomListSearchDataSource.Factory, + private val messageSearchService: MessageSearchService, + private val featureFlagService: FeatureFlagService, + private val latestEventFormatter: RoomLatestEventFormatter, + private val dateFormatter: DateFormatter, + private val fileSizeFormatter: FileSizeFormatter, + private val permalinkParser: PermalinkParser, + private val coroutineDispatchers: CoroutineDispatchers, + private val matrixClient: MatrixClient, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(coroutineScope: CoroutineScope): GlobalSearchPresenter + } + + @Composable + override fun present(): GlobalSearchState { + val isEnabled by produceState(false) { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it } + } + val roomListSearchDataSource = remember { roomListSearchDataSourceFactory.create(coroutineScope = coroutineScope) } + val queryState = rememberTextFieldState() + var isSearchActive by remember { mutableStateOf(false) } + var searchResults: AsyncData by remember { mutableStateOf(AsyncData.Uninitialized) } + var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) } + val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) } + + LaunchedEffect(queryState.text) { + // Add a delay to avoid performing too many searches in a short period of time, which can be expensive + delay(200.milliseconds) + + searchResults = if (queryState.text.isNotEmpty()) { + AsyncData.Loading(prevData = searchResults.dataOrNull()) + } else { + AsyncData.Uninitialized + } + + roomListSearchDataSource.setSearchQuery(queryState.text.toString()) + currentMessageSearch.setQuery(queryState.text.toString()) + } + + LaunchedEffect(currentTarget) { + if (queryState.text.isNotEmpty()) { + searchResults = AsyncData.Loading() + } + + when (currentTarget) { + GlobalSearchTarget.ROOMS -> { + roomListSearchDataSource.roomSummaries.collectLatest { results -> + // The room list data source always returns some values, even if the query is empty, + // so we only want to update the search results if the query is not empty + searchResults = if (queryState.text.isNotEmpty() && results.isNotEmpty()) { + AsyncData.Success(GlobalSearchResults.RoomListResults(results = results)) + } else { + AsyncData.Uninitialized + } + } + } + GlobalSearchTarget.MESSAGES -> { + currentMessageSearch.results.collectLatest { results -> + // Try processing the results in parallel to speed up the processing time, since we have to wait until we get the room info for each + // result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`. + val mappedResults = results.map { result -> + async(coroutineDispatchers.computation) { + val roomInfo = matrixClient.getRoomInfoFlow(result.roomId).first().getOrElse { return@async null } + val formattedTimestamp = dateFormatter.format( + timestamp = result.timestamp, + mode = DateFormatterMode.TimeOrDate, + useRelative = true + ) + + if (!result.content.isMediaContent()) { + mapMessageContent(result, roomInfo, formattedTimestamp) + } else { + mapMediaContent(result, roomInfo, formattedTimestamp) + } + } + } + .awaitAll() + .mapNotNull { it } + + searchResults = if (queryState.text.isNotEmpty() && mappedResults.isNotEmpty()) { + AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList())) + } else { + AsyncData.Uninitialized + } + } + } + } + } + + fun handleEvent(event: GlobalSearchEvent) { + when (event) { + GlobalSearchEvent.ClearQuery -> { + queryState.clearText() + } + GlobalSearchEvent.ToggleSearchVisibility -> { + isSearchActive = !isSearchActive + queryState.clearText() + } + is GlobalSearchEvent.UpdateVisibleRange -> { + when (currentTarget) { + GlobalSearchTarget.ROOMS -> coroutineScope.launch { + // TODO Maybe this is not needed? We're not displaying the latest event or timestamp info so maybe we dont need to subscribe + // to the room info for each room in the search results. + roomListSearchDataSource.updateVisibleRange(event.range) + } + GlobalSearchTarget.MESSAGES -> coroutineScope.launch { + val currentCount = (searchResults.dataOrNull() as? GlobalSearchResults.MessageSearchResults)?.results?.size ?: 0 + val currentPaginationState = currentMessageSearch.paginationState.value + val canPaginate = currentPaginationState is MessageSearchPaginationState.Idle && !currentPaginationState.endReached + if (event.range.last >= currentCount - 10 && canPaginate) { + currentMessageSearch.paginate() + } + } + } + } + is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target + } + } + + return GlobalSearchState( + isEnabled = isEnabled, + isSearchActive = isSearchActive, + queryState = queryState, + currentTarget = currentTarget, + results = searchResults, + eventSink = ::handleEvent, + ) + } + + private fun mapMessageContent( + result: MessageSearchResult, + roomInfo: RoomInfo, + formattedTimestamp: String, + ): MessageSearchResultItem.Message? { + val body = latestEventFormatter.format( + latestEvent = LatestEventValue.Remote( + timestamp = result.timestamp, + content = result.content, + senderId = result.senderId, + senderProfile = result.senderProfile, + isOwn = matrixClient.isMe(result.senderId), + ), + isDmRoom = false, + ) + return MessageSearchResultItem.Message( + messageSearchResult = result, + body = body?.toString() ?: "", + formattedTimestamp = formattedTimestamp, + roomInfo = roomInfo, + ) + } + + private fun mapMediaContent( + result: MessageSearchResult, + roomInfo: RoomInfo, + formattedTimestamp: String, + ): MessageSearchResultItem.Media? { + val messageType = when (val content = result.content) { + is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment + else -> return null + } + + val caption = messageType.toPlainText(permalinkParser, default = messageType.caption ?: messageType.filename) + + val thumbnailType = when (messageType) { + is ImageMessageType -> AttachmentThumbnailType.Image + is VideoMessageType -> AttachmentThumbnailType.Video + is AudioMessageType -> AttachmentThumbnailType.Audio + is VoiceMessageType -> AttachmentThumbnailType.Voice + is FileMessageType -> AttachmentThumbnailType.File + is StickerMessageType -> AttachmentThumbnailType.Image + } + val thumbnailSource = when (messageType) { + is ImageMessageType -> messageType.info?.thumbnailSource ?: messageType.source + is VideoMessageType -> messageType.info?.thumbnailSource + is AudioMessageType -> null + is VoiceMessageType -> null + is FileMessageType -> null + is StickerMessageType -> messageType.info?.thumbnailSource ?: messageType.source + } + val blurhash = when (messageType) { + is ImageMessageType -> messageType.info?.blurhash + is VideoMessageType -> messageType.info?.blurhash + is AudioMessageType -> null + is VoiceMessageType -> null + is FileMessageType -> null + is StickerMessageType -> messageType.info?.blurhash + } + + val extension = messageType.filename.split(".").takeIf { it.size > 1 }?.lastOrNull()?.uppercase() + val formattedSize = when (messageType) { + is ImageMessageType -> messageType.info?.size + is VideoMessageType -> messageType.info?.size + is AudioMessageType -> messageType.info?.size + is VoiceMessageType -> messageType.info?.size + is FileMessageType -> messageType.info?.size + is StickerMessageType -> messageType.info?.size + } + ?.let(fileSizeFormatter::format) + + val mediaContent = MediaSearchResultContent( + filename = messageType.filename, + extension = extension, + caption = caption, + formattedSize = formattedSize, + thumbnailSource = thumbnailSource, + thumbnailType = thumbnailType, + blurhash = blurhash + ) + + return MessageSearchResultItem.Media( + messageSearchResult = result, + mediaContent = mediaContent, + formattedTimestamp = formattedTimestamp, + roomInfo = roomInfo, + ) + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt new file mode 100644 index 0000000000..d623fd5594 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Immutable +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +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.media.MediaSource +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.search.MessageSearchResult +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class GlobalSearchState( + val isEnabled: Boolean, + val isSearchActive: Boolean, + val queryState: TextFieldState, + val currentTarget: GlobalSearchTarget, + val results: AsyncData, + val eventSink: (GlobalSearchEvent) -> Unit +) + +@Immutable +enum class GlobalSearchTarget { + ROOMS, + MESSAGES, +} + +@Immutable +sealed interface GlobalSearchResults { + data class RoomListResults(val results: ImmutableList) : GlobalSearchResults + data class MessageSearchResults(val results: ImmutableList) : GlobalSearchResults + + fun isEmpty(): Boolean = when (this) { + is RoomListResults -> results.isEmpty() + is MessageSearchResults -> results.isEmpty() + } +} + +sealed interface MessageSearchResultItem { + val roomId: RoomId + val eventId: EventId + val senderId: UserId + val senderName: String + val roomInfo: RoomInfo + val formattedTimestamp: String + + data class Message( + val messageSearchResult: MessageSearchResult, + val body: String, + override val roomInfo: RoomInfo, + override val formattedTimestamp: String, + ) : MessageSearchResultItem { + override val roomId: RoomId = messageSearchResult.roomId + override val eventId: EventId = messageSearchResult.eventId + override val senderId: UserId = messageSearchResult.senderId + override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId) + } + + data class Media( + val messageSearchResult: MessageSearchResult, + val mediaContent: MediaSearchResultContent, + override val roomInfo: RoomInfo, + override val formattedTimestamp: String, + ) : MessageSearchResultItem { + override val roomId: RoomId = messageSearchResult.roomId + override val eventId: EventId = messageSearchResult.eventId + override val senderId: UserId = messageSearchResult.senderId + override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId) + } +} + +data class MediaSearchResultContent( + val filename: String, + val extension: String?, + val caption: String?, + val formattedSize: String?, + val thumbnailSource: MediaSource?, + val thumbnailType: AttachmentThumbnailType, + val blurhash: String?, +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt new file mode 100644 index 0000000000..c4343b8982 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +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.media.MediaSource +import io.element.android.libraries.matrix.api.room.CallIntentConsensus +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import io.element.android.libraries.matrix.api.search.MessageSearchResult +import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList + +class GlobalSearchStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + // Not enabled, no preview + aGlobalSearchState(isEnabled = false), + aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.ROOMS, + queryState = TextFieldState("Query"), + results = AsyncData.Loading(), + ), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.ROOMS, + queryState = TextFieldState("Query"), + results = AsyncData.Success(GlobalSearchResults.RoomListResults(aRoomListRoomSummaryList())), + ), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.MESSAGES, + queryState = TextFieldState("Query"), + results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf( + MessageSearchResultItem.Message( + messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")), + body = "A message", + roomInfo = aRoomInfo(), + formattedTimestamp = "12:00", + ), + MessageSearchResultItem.Media( + messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")), + mediaContent = MediaSearchResultContent( + filename = "file.png", + extension = "PNG", + caption = "A caption", + formattedSize = "1 MB", + thumbnailSource = MediaSource("https://example.com/thumbnail.png"), + thumbnailType = AttachmentThumbnailType.Image, + blurhash = null, + ), + roomInfo = aRoomInfo(), + formattedTimestamp = "12:00", + ), + ))), + ), + aGlobalSearchState( + isSearchActive = true, + currentTarget = GlobalSearchTarget.MESSAGES, + queryState = TextFieldState("Query"), + results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())), + ), + ) +} + +fun aRoomInfo( + id: RoomId = RoomId("!roomId:server.org"), + name: String? = "A room", + rawName: String? = "A room raw name", + topic: String? = "A room topic", + avatarUrl: String? = "https://example.com/avatar.png", + isPublic: Boolean = true, + isDirect: Boolean = false, + isEncrypted: Boolean = false, + joinRule: JoinRule? = JoinRule.Public, + isSpace: Boolean = false, + successorRoom: SuccessorRoom? = null, + isFavorite: Boolean = false, + canonicalAlias: RoomAlias? = null, + alternativeAliases: List = emptyList(), + currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED, + inviter: RoomMember? = null, + activeMembersCount: Long = 2, + invitedMembersCount: Long = 1, + joinedMembersCount: Long = 1, + highlightCount: Long = 0, + notificationCount: Long = 0, + userDefinedNotificationMode: RoomNotificationMode? = null, + hasRoomCall: Boolean = false, + roomPowerLevels: RoomPowerLevels? = RoomPowerLevels( + values = RoomPowerLevelsValues( + ban = 0, + invite = 0, + kick = 0, + eventsDefault = 0, + stateDefault = 0, + redactEvents = 0, + roomName = 0, + roomAvatar = 0, + roomTopic = 0, + spaceChild = 0, + beacon = 0, + beaconInfo = 0 + ), + users = persistentMapOf(), + ), + activeRoomCallParticipants: List = emptyList(), + heroes: List = emptyList(), + pinnedEventIds: List = emptyList(), + roomCreators: List = emptyList(), + isMarkedUnread: Boolean = false, + numUnreadMessages: Long = 0, + numUnreadNotifications: Long = 0, + numUnreadMentions: Long = 0, + historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined, + roomVersion: String? = "11", + privilegedCreatorRole: Boolean = false, + isLowPriority: Boolean = false, + activeCallIntentConsensus: CallIntentConsensus = CallIntentConsensus.None, + isDm: Boolean = false, + fullyReadEventId: EventId? = null, +) = RoomInfo( + id = id, + name = name, + rawName = rawName, + topic = topic, + avatarUrl = avatarUrl, + isPublic = isPublic, + isDirect = isDirect, + isEncrypted = isEncrypted, + joinRule = joinRule, + isSpace = isSpace, + successorRoom = successorRoom, + isFavorite = isFavorite, + canonicalAlias = canonicalAlias, + alternativeAliases = alternativeAliases.toImmutableList(), + currentUserMembership = currentUserMembership, + inviter = inviter, + activeMembersCount = activeMembersCount, + invitedMembersCount = invitedMembersCount, + joinedMembersCount = joinedMembersCount, + highlightCount = highlightCount, + notificationCount = notificationCount, + userDefinedNotificationMode = userDefinedNotificationMode, + hasRoomCall = hasRoomCall, + roomPowerLevels = roomPowerLevels, + activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(), + heroes = heroes.toImmutableList(), + pinnedEventIds = pinnedEventIds.toImmutableList(), + creators = roomCreators.toImmutableList(), + isMarkedUnread = isMarkedUnread, + numUnreadMessages = numUnreadMessages, + numUnreadNotifications = numUnreadNotifications, + numUnreadMentions = numUnreadMentions, + historyVisibility = historyVisibility, + roomVersion = roomVersion, + privilegedCreatorRole = privilegedCreatorRole, + isLowPriority = isLowPriority, + activeCallIntentConsensus = activeCallIntentConsensus, + isDm = isDm, + fullyReadEventId = fullyReadEventId, +) + +fun aMessageContent( + body: String = "body", + inReplyTo: InReplyTo? = null, + isEdited: Boolean = false, + threadInfo: EventThreadInfo? = null, + messageType: MessageType = TextMessageType( + body = body, + formatted = null + ) +) = MessageContent( + body = body, + inReplyTo = inReplyTo, + isEdited = isEdited, + threadInfo = threadInfo, + type = messageType +) + +internal fun aMessageSearchResult( + roomId: RoomId = RoomId("!roomId:server.org"), + eventId: EventId = EventId("\$eventId:server.org"), + senderId: UserId = UserId("@user:server.org"), + senderProfile: ProfileDetails = aProfileDetailsReady(), + content: MessageContent = aMessageContent(), + timestamp: Long = 0L, +) = MessageSearchResult( + roomId = roomId, + eventId = eventId, + senderId = senderId, + senderProfile = senderProfile, + content = content, + timestamp = timestamp, +) + +internal fun aGlobalSearchState( + isEnabled: Boolean = true, + isSearchActive: Boolean = false, + queryState: TextFieldState = TextFieldState(), + currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS, + results: AsyncData = AsyncData.Uninitialized, + eventSink: (GlobalSearchEvent) -> Unit = {}, +) = GlobalSearchState( + isEnabled = isEnabled, + isSearchActive = isSearchActive, + queryState = queryState, + currentTarget = currentTarget, + results = results, + eventSink = eventSink +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt new file mode 100644 index 0000000000..593a5ed6bf --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt @@ -0,0 +1,564 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.R +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.modifiers.niceClickable +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.roundToPx +import io.element.android.libraries.designsystem.theme.components.FilledTextField +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.SegmentedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect +import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding +import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun GlobalSearchView( + state: GlobalSearchState, + onSelectSearchResult: (RoomId, EventId?) -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(enabled = state.isSearchActive) { + state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + } + + AnimatedVisibility( + visible = state.isSearchActive, + enter = fadeIn(), + exit = fadeOut(), + ) { + Column(modifier = modifier) { + GlobalSearchContent( + state = state, + onBackButtonClick = { + state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + }, + onSelectSearchResult = onSelectSearchResult, + ) + } + } +} + +@Composable +private fun GlobalSearchContent( + state: GlobalSearchState, + onBackButtonClick: () -> Unit, + onSelectSearchResult: (RoomId, EventId?) -> Unit, +) { + val borderColor = MaterialTheme.colorScheme.tertiary + val strokeWidth = 1.dp + + val drawBottomLineModifier = Modifier.drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = strokeWidth.value + ) + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { BackButton(onClick = onBackButtonClick) }, + title = { + // The stateSaver will keep the selection state when returning to this UI + val focusRequester = remember { FocusRequester() } + val searchLabel = stringResource(CommonStrings.action_search) + FilledTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .semantics { contentDescription = searchLabel }, + state = state.queryState, + lineLimits = TextFieldLineLimits.SingleLine, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + ), + trailingIcon = if (state.queryState.text.isNotEmpty()) { + @Composable { + IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.a11y_clear_search_field) + ) + } + } + } else { + null + }, + ) + + LaunchedEffect(Unit) { + if (!focusRequester.restoreFocusedChild()) { + focusRequester.requestFocus() + } + focusRequester.saveFocusedChild() + } + } + ) + }, + contentWindowInsets = scaffoldScrollableContentInsets, + ) { padding -> + Column( + modifier = Modifier.padding(padding), + ) { + SingleChoiceSegmentedButtonRow( + modifier = drawBottomLineModifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp) + ) { + SegmentedButton( + index = 0, + count = 2, + selected = state.currentTarget == GlobalSearchTarget.ROOMS, + onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) }, + text = stringResource(R.string.search_section_chats), + ) + + SegmentedButton( + index = 1, + count = 2, + selected = state.currentTarget == GlobalSearchTarget.MESSAGES, + onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) }, + text = stringResource(R.string.search_section_messages), + ) + } + + val lazyListState = rememberLazyListState() + OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> + state.eventSink(GlobalSearchEvent.UpdateVisibleRange(visibleRange)) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = lazyColumnContentPadding, + state = lazyListState, + ) { + val results = state.results.dataOrNull() + when { + state.results.isUninitialized() -> startSearching() + state.results.isLoading() -> loading() + results?.isEmpty() == true -> emptySearchResults(query = state.queryState.text.toString()) + results is GlobalSearchResults.RoomListResults -> roomListResults( + results = results.results, + onRoomClick = { roomId -> onSelectSearchResult(roomId, null) }, + ) + results is GlobalSearchResults.MessageSearchResults -> messageSearchResults( + results = results.results, + onSearchResultSelected = onSelectSearchResult, + ) + } + } + } + } +} + +private fun LazyListScope.startSearching() { + item { + IconTitleSubtitleMolecule( + modifier = Modifier + .fillMaxWidth() + .padding(40.dp), + title = stringResource(R.string.start_searching_title), + subTitle = stringResource(R.string.start_searching_subtitle), + iconStyle = BigIcon.Style.Default(CompoundIcons.Search()), + ) + } +} + +private fun LazyListScope.loading() { + item { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } +} + +private fun LazyListScope.emptySearchResults(query: String) { + item { + IconTitleSubtitleMolecule( + modifier = Modifier + .fillMaxWidth() + .padding(40.dp), + title = stringResource(R.string.search_no_results_title), + subTitle = stringResource(R.string.search_no_results_subtitle, query), + iconStyle = BigIcon.Style.Default(CompoundIcons.Search()), + ) + } +} + +private fun LazyListScope.roomListResults( + results: ImmutableList, + onRoomClick: (RoomId) -> Unit, +) { + itemsIndexed( + items = results, + key = { _, roomSummary -> roomSummary.roomId }, + ) { index, result -> + Row( + modifier = Modifier + .niceClickable(onClick = { onRoomClick(result.roomId) }) + .fillMaxWidth() + .addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = result.avatarData.copy(size = AvatarSize.SearchRoomListItem), + avatarType = AvatarType.Room( + heroes = result.heroes.map { it.copy(size = AvatarSize.SearchRoomListItem) }.toImmutableList(), + isTombstoned = result.isTombstoned, + ), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = result.name ?: result.roomId.value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgRegular, + ) + + if (result.isDm) { + val userId = result.heroes.firstOrNull()?.id + userId?.let { + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } else { + result.canonicalAlias?.let { + Text( + text = it.value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } + } + } +} + +private fun LazyListScope.messageSearchResults( + results: ImmutableList, + onSearchResultSelected: (RoomId, EventId?) -> Unit, +) { + if (results.isNotEmpty()) { + item { + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + text = stringResource(R.string.search_from_your_messages_header), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textSecondary, + ) + } + } + itemsIndexed( + items = results, + contentType = { _, messageSearchResult -> messageSearchResult::class.java }, + key = { _, messageSearchResult -> messageSearchResult.eventId }, + ) { index, result -> + val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) }) + when (result) { + is MessageSearchResultItem.Message -> TextMessageSearchResultItemView( + modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), + avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), + avatarType = AvatarType.Room( + heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(), + isTombstoned = result.roomInfo.successorRoom != null + ), + roomName = result.roomInfo.name ?: result.roomInfo.id.value, + body = result.body, + formattedTimestamp = result.formattedTimestamp, + ) + is MessageSearchResultItem.Media -> { + MediaMessageSearchResultItemView( + modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), + avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), + avatarType = AvatarType.Room( + heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(), + isTombstoned = result.roomInfo.successorRoom != null + ), + roomName = result.roomInfo.name ?: result.roomInfo.id.value, + content = result.mediaContent, + formattedTimestamp = result.formattedTimestamp, + ) + } + } + } +} + +@Composable +private fun TextMessageSearchResultItemView( + avatarData: AvatarData, + avatarType: AvatarType, + roomName: String, + body: String, + formattedTimestamp: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = avatarData, + avatarType = avatarType, + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + modifier = Modifier.weight(1f), + text = roomName, + maxLines = 1, + style = ElementTheme.typography.fontBodyLgMedium, + ) + Text( + text = formattedTimestamp, + maxLines = 1, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + Text( + text = body, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } +} + +@Composable +private fun MediaMessageSearchResultItemView( + avatarData: AvatarData, + avatarType: AvatarType, + roomName: String, + content: MediaSearchResultContent, + formattedTimestamp: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = avatarData, + avatarType = avatarType, + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + modifier = Modifier.weight(1f), + text = roomName, + maxLines = 1, + style = ElementTheme.typography.fontBodyLgMedium, + ) + Text( + text = formattedTimestamp, + maxLines = 1, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + + content.caption?.let { + Text( + text = it, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + Row( + modifier = Modifier + .fillMaxWidth(1f) + .background(ElementTheme.colors.bgSubtleSecondary, shape = RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AttachmentThumbnail( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(4.dp)), + info = AttachmentThumbnailInfo( + type = content.thumbnailType, + thumbnailSource = content.thumbnailSource, + textContent = null, + blurHash = content.blurhash, + ), + thumbnailSize = 36.dp.roundToPx().toLong(), + backgroundColor = ElementTheme.colors.bgCanvasDefault, + ) + + Column { + Text( + text = content.filename, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgRegular, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + content.extension?.let { + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + content.formattedSize?.let { + Text( + text = "($it)", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } + } + } + } +} + +private fun Modifier.addSeparatorLine(color: Color, isLastItem: Boolean): Modifier { + if (isLastItem) return this + + return this.drawBehind { + val strokeWidthPx = 1.dp.toPx() + drawLine( + color = color, + start = Offset(0f, size.height - strokeWidthPx), + end = Offset(size.width, size.height - strokeWidthPx), + strokeWidth = strokeWidthPx, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun GlobalSearchViewPreview( + @PreviewParameter(GlobalSearchStateProvider::class) state: GlobalSearchState, +) { + ElementPreview { + GlobalSearchView( + state = state, + onSelectSearchResult = { _, _ -> }, + ) + } +} diff --git a/features/home/impl/src/main/res/values/temporary.xml b/features/home/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..c44001e5a6 --- /dev/null +++ b/features/home/impl/src/main/res/values/temporary.xml @@ -0,0 +1,17 @@ + + + + + Start searching… + Search for chats and messages + Chats + From your messages + Messages + No results + There are no results for “%1$s”. Try a new search term. + diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt index ac7e426e65..99c45c67d9 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt @@ -8,9 +8,11 @@ package io.element.android.libraries.designsystem.components.avatar +import androidx.compose.runtime.Immutable import io.element.android.libraries.core.data.tryOrNull import java.text.BreakIterator +@Immutable data class AvatarData( val id: String, val name: String?, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index eb27780073..b91f8c405c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -8,9 +8,11 @@ package io.element.android.libraries.designsystem.components.avatar +import androidx.compose.runtime.Immutable import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +@Immutable enum class AvatarSize(val dp: Dp) { CurrentUserTopBar(32.dp), CurrentRoomTopBar(32.dp), @@ -18,6 +20,7 @@ enum class AvatarSize(val dp: Dp) { IncomingCall(140.dp), RoomDetailsHeader(96.dp), RoomListItem(52.dp), + SearchRoomListItem(32.dp), ThreadsListItem(52.dp), SpaceListItem(52.dp), From 3ca5081f33b36d774fd7f60ec52f781f93378890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 20 Aug 2026 13:56:29 +0200 Subject: [PATCH 03/17] Integrate the new screen with the existing home screen, display it only when the search feature flag is enabled --- .../android/appnav/LoggedInFlowNode.kt | 4 +- .../features/home/api/HomeEntryPoint.kt | 3 +- .../features/home/impl/HomeFlowNode.kt | 8 ++- .../android/features/home/impl/HomeView.kt | 57 +++++++++++++------ .../home/impl/roomlist/RoomListPresenter.kt | 8 +++ .../home/impl/roomlist/RoomListState.kt | 2 + .../impl/roomlist/RoomListStateProvider.kt | 4 ++ .../home/impl/DefaultHomeEntryPointTest.kt | 3 +- 8 files changed, 66 insertions(+), 23 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index cd071131d9..dab9b7828c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -335,11 +335,11 @@ class LoggedInFlowNode( } NavTarget.Home -> { val callback = object : HomeEntryPoint.Callback { - override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) { + override fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) { lifecycleScope.launch { attachRoom( roomIdOrAlias = roomId.toRoomIdOrAlias(), - initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom), + initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom, eventId = eventId), clearBackstack = false, ) } diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt index 1a90245611..1c6a0a906e 100644 --- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -12,6 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.JoinedRoom @@ -23,7 +24,7 @@ interface HomeEntryPoint : FeatureEntryPoint { ): Node interface Callback : Plugin { - fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) + fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) fun navigateToCreateRoom() fun navigateToCreateSpace() fun navigateToSettings() diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index b7b05cca87..20f27f67bd 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.utils.DelayedVisibility import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.CoroutineScope @@ -183,6 +184,7 @@ class HomeFlowNode( fun navigateToRoom( roomId: RoomId, + eventId: EventId?, ) { if (!loadingJoinedRoomJob.value.isUninitialized()) { Timber.w("Already loading a room, ignoring navigateToRoom for $roomId") @@ -195,7 +197,7 @@ class HomeFlowNode( }.fold( onSuccess = { joinedRoom -> if (isActive) { - callback.navigateToRoom(roomId, joinedRoom) + callback.navigateToRoom(roomId = roomId, eventId = eventId, joinedRoom = joinedRoom) loadingJoinedRoomJob.value = AsyncData.Success(coroutineContext.job) // Wait a bit before resetting the state to avoid allowing to open several rooms delay(200.milliseconds) @@ -205,7 +207,7 @@ class HomeFlowNode( onFailure = { // If the operation wasn't cancelled, navigate without the room, using the room id if (it !is CancellationException) { - callback.navigateToRoom(roomId, null) + callback.navigateToRoom(roomId = roomId, eventId = null, joinedRoom = null) } loadingJoinedRoomJob.value = AsyncData.Failure(error = it, prevData = coroutineContext.job) // Wait a bit before resetting the state to avoid allowing to open several rooms @@ -233,7 +235,7 @@ class HomeFlowNode( acceptDeclineInviteView = { acceptDeclineInviteView.Render( state = state.roomListState.acceptDeclineInviteState, - onAcceptInviteSuccess = ::navigateToRoom, + onAcceptInviteSuccess = { navigateToRoom(roomId = it, eventId = null) }, onDeclineInviteSuccess = { }, modifier = Modifier ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index f5a2400f2f..97b43b17cc 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -55,6 +55,8 @@ import io.element.android.features.home.impl.roomlist.RoomListContextMenu import io.element.android.features.home.impl.roomlist.RoomListDeclineInviteMenu import io.element.android.features.home.impl.roomlist.RoomListEvent import io.element.android.features.home.impl.roomlist.RoomListState +import io.element.android.features.home.impl.search.GlobalSearchEvent +import io.element.android.features.home.impl.search.GlobalSearchView import io.element.android.features.home.impl.search.RoomListSearchView import io.element.android.features.home.impl.spacefilters.SpaceFiltersEvent import io.element.android.features.home.impl.spacefilters.SpaceFiltersState @@ -73,6 +75,7 @@ import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -80,7 +83,7 @@ import kotlinx.coroutines.launch @Composable fun HomeView( homeState: HomeState, - onRoomClick: (RoomId) -> Unit, + onRoomClick: (RoomId, EventId?) -> Unit, onSettingsClick: () -> Unit, onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, @@ -122,22 +125,34 @@ fun HomeView( state = homeState, onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, - onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) }, + onRoomClick = { roomId -> if (firstThrottler.canHandle()) onRoomClick(roomId, null) }, onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() }, onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() }, onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() }, onMenuActionClick = onMenuActionClick, ) - // This overlaid view will only be visible when state.displaySearchResults is true - RoomListSearchView( - state = state.searchState, - eventSink = state.eventSink, - hideInvitesAvatars = state.hideInvitesAvatars, - onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) }, - modifier = Modifier - .fillMaxSize() - .background(ElementTheme.colors.bgCanvasDefault) - ) + + if (state.globalSearchState.isEnabled) { + GlobalSearchView( + state = state.globalSearchState, + onSelectSearchResult = { roomId, eventId -> if (firstThrottler.canHandle()) onRoomClick(roomId, eventId) }, + modifier = Modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault), + ) + } else { + // This overlaid view will only be visible when state.displaySearchResults is true + RoomListSearchView( + state = state.searchState, + eventSink = state.eventSink, + hideInvitesAvatars = state.hideInvitesAvatars, + onRoomClick = { roomId -> if (firstThrottler.canHandle()) onRoomClick(roomId, null) }, + modifier = Modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault) + ) + } + acceptDeclineInviteView() } } @@ -186,8 +201,18 @@ private fun HomeScaffold( selectedNavigationItem = state.currentHomeNavigationBarItem, currentUserAndNeighbors = state.currentUserAndNeighbors, showAvatarIndicator = state.showAvatarIndicator, - areSearchResultsDisplayed = roomListState.searchState.isSearchActive, - onToggleSearch = { roomListState.eventSink(RoomListEvent.ToggleSearchResults) }, + areSearchResultsDisplayed = if (roomListState.globalSearchState.isEnabled) { + roomListState.globalSearchState.isSearchActive + } else { + roomListState.searchState.isSearchActive + }, + onToggleSearch = { + if (roomListState.globalSearchState.isEnabled) { + roomListState.globalSearchState.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + } else { + roomListState.eventSink(RoomListEvent.ToggleSearchResults) + } + }, onMenuActionClick = onMenuActionClick, onOpenSettings = onOpenSettings, onAccountSwitch = { @@ -349,7 +374,7 @@ internal fun RoomListRoomSummary.contentType() = displayType.ordinal internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state: HomeState) = ElementPreview { HomeView( homeState = state, - onRoomClick = {}, + onRoomClick = { _, _ -> }, onSettingsClick = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, @@ -369,7 +394,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state: internal fun HomeViewA11yPreview() = ElementPreview { HomeView( homeState = aHomeState(), - onRoomClick = {}, + onRoomClick = { _, _ -> }, onSettingsClick = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index fe09bd7cd1..47b02cac08 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -30,6 +30,7 @@ import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.into +import io.element.android.features.home.impl.search.GlobalSearchPresenter import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState @@ -85,10 +86,15 @@ class RoomListPresenter( private val announcementService: AnnouncementService, private val coldStartWatcher: AnalyticsColdStartWatcher, private val spaceFiltersPresenter: Presenter, + private val globalSearchPresenterFactory: GlobalSearchPresenter.Factory, private val featureFlagService: FeatureFlagService, ) : Presenter { private val encryptionService = client.encryptionService + private val globalSearchPresenter by lazy { + globalSearchPresenterFactory.create(coroutineScope = client.sessionCoroutineScope) + } + @Composable override fun present(): RoomListState { val coroutineScope = rememberCoroutineScope() @@ -97,6 +103,7 @@ class RoomListPresenter( val searchState = searchPresenter.present() val spaceFiltersState = spaceFiltersPresenter.present() val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() + val globalSearchState = globalSearchPresenter.present() LaunchedEffect(Unit) { roomListDataSource.launchIn(this) @@ -177,6 +184,7 @@ class RoomListPresenter( leaveRoomState = leaveRoomState, filtersState = filtersState, searchState = searchState, + globalSearchState = globalSearchState, spaceFiltersState = spaceFiltersState, contentState = contentState, acceptDeclineInviteState = acceptDeclineInviteState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt index e184a5a57f..4164c56e9d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt @@ -11,6 +11,7 @@ package io.element.android.features.home.impl.roomlist import androidx.compose.runtime.Immutable import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState @@ -27,6 +28,7 @@ data class RoomListState( val leaveRoomState: LeaveRoomState, val filtersState: RoomListFiltersState, val searchState: RoomListSearchState, + val globalSearchState: GlobalSearchState, val spaceFiltersState: SpaceFiltersState, val contentState: RoomListContentState, val acceptDeclineInviteState: AcceptDeclineInviteState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt index 97600e0247..034e7acd42 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt @@ -16,7 +16,9 @@ import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomSummaryDisplayType import io.element.android.features.home.impl.model.aRoomListRoomSummary import io.element.android.features.home.impl.model.anInviteSender +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchState +import io.element.android.features.home.impl.search.aGlobalSearchState import io.element.android.features.home.impl.search.aRoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState @@ -53,6 +55,7 @@ internal fun aRoomListState( declineInviteMenu: RoomListState.DeclineInviteMenu = RoomListState.DeclineInviteMenu.Hidden, leaveRoomState: LeaveRoomState = aLeaveRoomState(), searchState: RoomListSearchState = aRoomListSearchState(), + globalSearchState: GlobalSearchState = aGlobalSearchState(), filtersState: RoomListFiltersState = aRoomListFiltersState(), spaceFiltersState: SpaceFiltersState = anUnselectedSpaceFiltersState(), contentState: RoomListContentState = aRoomsContentState(), @@ -66,6 +69,7 @@ internal fun aRoomListState( leaveRoomState = leaveRoomState, filtersState = filtersState, searchState = searchState, + globalSearchState = globalSearchState, spaceFiltersState = spaceFiltersState, contentState = contentState, acceptDeclineInviteState = acceptDeclineInviteState, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index 5e8570adec..c0cb5f7c01 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -12,6 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -44,7 +45,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { ) } val callback = object : HomeEntryPoint.Callback { - override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) = lambdaError() + override fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) = lambdaError() override fun navigateToCreateRoom() = lambdaError() override fun navigateToCreateSpace() = lambdaError() override fun navigateToSettings() = lambdaError() From 8bdce7cfd6803fecf7a079537491413ea2e17312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 20 Aug 2026 14:18:06 +0200 Subject: [PATCH 04/17] Add tests --- .../impl/roomlist/RoomListPresenterTest.kt | 9 + .../home/impl/roomlist/RoomListViewTest.kt | 2 +- .../impl/search/GlobalSearchPresenterTest.kt | 237 ++++++++++++++++++ 3 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index 64dfc46ee2..8cfc47fe43 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -18,9 +18,11 @@ import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFact import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.aRoomListFiltersState import io.element.android.features.home.impl.model.createRoomListRoomSummary +import io.element.android.features.home.impl.search.GlobalSearchPresenter import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.search.aRoomListSearchState +import io.element.android.features.home.impl.search.createGlobalSearchPresenter import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.aDisabledSpaceFiltersState import io.element.android.features.invite.api.SeenInvitesStore @@ -80,6 +82,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -680,6 +683,12 @@ class RoomListPresenterTest { analyticsService = FakeAnalyticsService(), ), searchPresenter = searchPresenter, + globalSearchPresenterFactory = object : GlobalSearchPresenter.Factory { + override fun create(coroutineScope: CoroutineScope) = createGlobalSearchPresenter( + matrixClient = client, + coroutineScope = coroutineScope, + ) + }, filtersPresenter = filtersPresenter, spaceFiltersPresenter = spaceFiltersPresenter, analyticsService = analyticsService, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index 773f428917..bd18cd903e 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -277,7 +277,7 @@ private fun AndroidComposeUiTest.setRoomListView( setSafeContent { HomeView( homeState = aHomeState(roomListState = state), - onRoomClick = onRoomClick, + onRoomClick = { roomId, _ -> onRoomClick(roomId) }, onSettingsClick = onSettingsClick, onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt new file mode 100644 index 0000000000..7469ea4e84 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter +import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.search.FakeMessageSearch +import io.element.android.libraries.matrix.test.search.FakeMessageSearchService +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.util.Optional + +class GlobalSearchPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createGlobalSearchPresenter() + presenter.test { + awaitItem().let { state -> + assertThat(state.isEnabled).isFalse() + assertThat(state.isSearchActive).isFalse() + assertThat(state.queryState.text.toString()).isEmpty() + assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS) + assertThat(state.results).isEqualTo(AsyncData.Uninitialized) + } + } + } + + @Test + fun `present - isEnabled reflects the feature flag`() = runTest { + val presenter = createGlobalSearchPresenter( + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MessageSearch.key to true), + ), + ) + presenter.test { + // isEnabled is emitted asynchronously via produceState, so it starts as false and flips to true + var state = awaitItem() + while (!state.isEnabled) { + state = awaitItem() + } + assertThat(state.isEnabled).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - toggle search visibility`() = runTest { + val presenter = createGlobalSearchPresenter() + presenter.test { + awaitItem().let { state -> + assertThat(state.isSearchActive).isFalse() + state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + } + awaitItem().let { state -> + assertThat(state.isSearchActive).isTrue() + state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + } + awaitItem().let { state -> + assertThat(state.isSearchActive).isFalse() + } + } + } + + @Test + fun `present - update target`() = runTest { + val presenter = createGlobalSearchPresenter() + presenter.test { + awaitItem().let { state -> + assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS) + state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) + } + awaitItem().let { state -> + assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.MESSAGES) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - clear query`() = runTest { + val presenter = createGlobalSearchPresenter() + presenter.test { + val initialState = awaitItem() + initialState.queryState.edit { append("A query") } + awaitItem().let { state -> + assertThat(state.queryState.text.toString()).isEqualTo("A query") + state.eventSink(GlobalSearchEvent.ClearQuery) + } + awaitItem().let { state -> + assertThat(state.queryState.text.toString()).isEmpty() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - room search results are emitted when the room list changes`() = runTest { + val roomList = FakeDynamicRoomList() + val roomListService = FakeRoomListService(createRoomListLambda = { roomList }) + val presenter = createGlobalSearchPresenter(roomListService = roomListService) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.results).isEqualTo(AsyncData.Uninitialized) + + initialState.queryState.edit { append("A query") } + // Let the query be propagated to the data source before emitting results + testScheduler.advanceUntilIdle() + roomList.summaries.emit(listOf(aRoomSummary())) + + val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() + val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results + assertThat(results).hasSize(1) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - message search results are mapped when the message search emits`() = runTest { + val messageSearch = FakeMessageSearch() + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } + } + val presenter = createGlobalSearchPresenter( + messageSearchService = FakeMessageSearchService(messageSearch), + matrixClient = matrixClient, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) + initialState.queryState.edit { append("A query") } + testScheduler.advanceUntilIdle() + + messageSearch.emitResults(persistentListOf(aMessageSearchResult())) + testScheduler.advanceUntilIdle() + + val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() + val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results + assertThat(results).hasSize(1) + assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest { + val messageSearch = FakeMessageSearch() + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } + } + val presenter = createGlobalSearchPresenter( + messageSearchService = FakeMessageSearchService(messageSearch), + matrixClient = matrixClient, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) + initialState.queryState.edit { append("A query") } + testScheduler.advanceUntilIdle() + + messageSearch.emitResults(persistentListOf(aMessageSearchResult())) + val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() + + successState.eventSink(GlobalSearchEvent.UpdateVisibleRange(IntRange(0, 0))) + runCurrent() + testScheduler.advanceUntilIdle() + + assertThat(messageSearch.paginateCallCount).isEqualTo(1) + cancelAndIgnoreRemainingEvents() + } + } +} + +internal fun TestScope.createGlobalSearchPresenter( + roomListService: RoomListService = FakeRoomListService(), + messageSearchService: MessageSearchService = FakeMessageSearchService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + latestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(), + dateFormatter: DateFormatter = FakeDateFormatter(), + fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(), + permalinkParser: PermalinkParser = FakePermalinkParser(), + matrixClient: MatrixClient = FakeMatrixClient(), + coroutineScope: CoroutineScope = backgroundScope, +): GlobalSearchPresenter { + return GlobalSearchPresenter( + coroutineScope = coroutineScope, + roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory { + override fun create(coroutineScope: CoroutineScope): RoomListSearchDataSource { + return RoomListSearchDataSource( + coroutineScope = coroutineScope, + roomListService = roomListService, + coroutineDispatchers = testCoroutineDispatchers(), + roomSummaryFactory = aRoomListRoomSummaryFactory(), + ) + } + }, + messageSearchService = messageSearchService, + featureFlagService = featureFlagService, + latestEventFormatter = latestEventFormatter, + dateFormatter = dateFormatter, + fileSizeFormatter = fileSizeFormatter, + permalinkParser = permalinkParser, + coroutineDispatchers = testCoroutineDispatchers(), + matrixClient = matrixClient, + ) +} From f854fbd70a200efc459ae8240b00caf85ca018a1 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 12 Aug 2026 12:06:08 +0000 Subject: [PATCH 05/17] Update screenshots --- .../features.home.impl.search_GlobalSearchView_Day_0_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Day_1_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Day_2_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Day_3_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Day_4_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Day_5_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Night_0_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Night_1_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Night_2_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Night_3_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Night_4_en.png | 3 +++ .../features.home.impl.search_GlobalSearchView_Night_5_en.png | 3 +++ .../images/features.home.impl_HomeView_Day_12_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_12_en.png | 4 ++-- 14 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png new file mode 100644 index 0000000000..955af7bb9a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a856467fba0134aefcad2428493ce9169fccf90705b71d540c2eed935d9b625e +size 22639 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png new file mode 100644 index 0000000000..a4fa915963 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70386b8f6a8998f246819c6d643543babcf41ba72db86db40559a755876c7650 +size 12144 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png new file mode 100644 index 0000000000..0a3484d59e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1c9b463f6fa8fa8c1e9c8a776734879201f506f4cad37ecdba05baabe49b1b3 +size 26668 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png new file mode 100644 index 0000000000..a8bf17e259 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b58ab253d632a15dd5274351ec47dd293099a3fbf7dcac16103d8e63423b09b9 +size 73864 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png new file mode 100644 index 0000000000..ca2936923c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc3946915978e17cf404469273a5ff174c12f53da8fc3cff53ac6df8721e0182 +size 23522 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png new file mode 100644 index 0000000000..bd73e322ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12020ba97720f2374f9ace172693f08be01522e4ca30d3970efa91d802581e81 +size 3657 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png new file mode 100644 index 0000000000..cfef0a6883 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad20c730bb320093a5c639938430e6851244e4425b46f3ae10262d9ca96215a4 +size 21972 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png new file mode 100644 index 0000000000..ec806ea229 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb20125a38d4d9436bee64de8f8d09d4afa372a72b7e33325b8d69f5b5760cdd +size 11694 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png new file mode 100644 index 0000000000..aa0fd55433 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20701c8eca52f816c18c788f6ecef485255e3059b5790fc1705c52ede5d75566 +size 26708 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png new file mode 100644 index 0000000000..004b62009e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07388f4fed1ecfd51aa2c98c0014b08aee983b50e691edef95c9d290c2c948aa +size 72281 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png new file mode 100644 index 0000000000..2864a9ffc8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e305c212a008854c19255f91824631d55d9fb523799ecad9b50039aa8b178323 +size 23053 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png index 11ffe943aa..b5f80f2faf 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7a1e3fcf68d17303f7082f2f0ee124a166778bad097a9e9e49e4a2de3c63c506 -size 5700 +oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 +size 67694 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png index f57f515cee..fe171e26b7 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aa9cd96bd8827121d24abaa44055485e34d5f8f3ee24c845a46247a094ca5b83 -size 5566 +oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 +size 62572 From 9a2d23c8a126a0df1f4e40a8401dbe2bdff125b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 12:07:38 +0200 Subject: [PATCH 06/17] Remove unnecessary `GlobalSearchPresenter.Factory` --- .../home/impl/roomlist/RoomListPresenter.kt | 8 ++------ .../home/impl/search/GlobalSearchPresenter.kt | 16 +++++----------- .../home/impl/roomlist/RoomListPresenterTest.kt | 9 +++------ .../impl/search/GlobalSearchPresenterTest.kt | 2 -- 4 files changed, 10 insertions(+), 25 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 47b02cac08..1c21177988 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -30,7 +30,7 @@ import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.into -import io.element.android.features.home.impl.search.GlobalSearchPresenter +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState @@ -86,15 +86,11 @@ class RoomListPresenter( private val announcementService: AnnouncementService, private val coldStartWatcher: AnalyticsColdStartWatcher, private val spaceFiltersPresenter: Presenter, - private val globalSearchPresenterFactory: GlobalSearchPresenter.Factory, + private val globalSearchPresenter: Presenter, private val featureFlagService: FeatureFlagService, ) : Presenter { private val encryptionService = client.encryptionService - private val globalSearchPresenter by lazy { - globalSearchPresenterFactory.create(coroutineScope = client.sessionCoroutineScope) - } - @Composable override fun present(): RoomListState { val coroutineScope = rememberCoroutineScope() diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index 11de6e7c80..0014d70ff8 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -15,16 +15,16 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.di.SessionScope import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags @@ -48,7 +48,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.isMediaConten import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.messages.toPlainText import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay @@ -58,9 +57,8 @@ import kotlinx.coroutines.launch import kotlin.jvm.optionals.getOrElse import kotlin.time.Duration.Companion.milliseconds -@AssistedInject +@ContributesBinding(SessionScope::class) class GlobalSearchPresenter( - @Assisted private val coroutineScope: CoroutineScope, private val roomListSearchDataSourceFactory: RoomListSearchDataSource.Factory, private val messageSearchService: MessageSearchService, private val featureFlagService: FeatureFlagService, @@ -71,13 +69,9 @@ class GlobalSearchPresenter( private val coroutineDispatchers: CoroutineDispatchers, private val matrixClient: MatrixClient, ) : Presenter { - @AssistedFactory - fun interface Factory { - fun create(coroutineScope: CoroutineScope): GlobalSearchPresenter - } - @Composable override fun present(): GlobalSearchState { + val coroutineScope = rememberCoroutineScope() val isEnabled by produceState(false) { featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it } } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index 8cfc47fe43..470aaaa441 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -683,12 +683,9 @@ class RoomListPresenterTest { analyticsService = FakeAnalyticsService(), ), searchPresenter = searchPresenter, - globalSearchPresenterFactory = object : GlobalSearchPresenter.Factory { - override fun create(coroutineScope: CoroutineScope) = createGlobalSearchPresenter( - matrixClient = client, - coroutineScope = coroutineScope, - ) - }, + globalSearchPresenter = createGlobalSearchPresenter( + matrixClient = client, + ), filtersPresenter = filtersPresenter, spaceFiltersPresenter = spaceFiltersPresenter, analyticsService = analyticsService, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt index 7469ea4e84..3f5fe802f9 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -211,10 +211,8 @@ internal fun TestScope.createGlobalSearchPresenter( fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(), permalinkParser: PermalinkParser = FakePermalinkParser(), matrixClient: MatrixClient = FakeMatrixClient(), - coroutineScope: CoroutineScope = backgroundScope, ): GlobalSearchPresenter { return GlobalSearchPresenter( - coroutineScope = coroutineScope, roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory { override fun create(coroutineScope: CoroutineScope): RoomListSearchDataSource { return RoomListSearchDataSource( From 3a946475f73945ebb80cf7f487643b2e7bf9d445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 12:37:47 +0200 Subject: [PATCH 07/17] Fix search results never displaying an empty state Use the pagination state to understand when a search finished with no results --- .../home/impl/search/GlobalSearchPresenter.kt | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index 0014d70ff8..aeee0ddfeb 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -47,13 +47,16 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageT import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.messages.toPlainText +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import timber.log.Timber import kotlin.jvm.optionals.getOrElse import kotlin.time.Duration.Companion.milliseconds @@ -92,8 +95,11 @@ class GlobalSearchPresenter( AsyncData.Uninitialized } - roomListSearchDataSource.setSearchQuery(queryState.text.toString()) - currentMessageSearch.setQuery(queryState.text.toString()) + launch { roomListSearchDataSource.setSearchQuery(queryState.text.toString()) } + launch { + currentMessageSearch.setQuery(queryState.text.toString()) + .onFailure { Timber.e(it, "Could not set query for message search") } + } } LaunchedEffect(currentTarget) { @@ -114,7 +120,15 @@ class GlobalSearchPresenter( } } GlobalSearchTarget.MESSAGES -> { - currentMessageSearch.results.collectLatest { results -> + Timber.d("Message search found ${currentMessageSearch.results.value.size} items") + combine( + currentMessageSearch.results, + currentMessageSearch.paginationState, + ) { results, paginationState -> + Pair(results, paginationState) + }.collectLatest { (results, paginationState) -> + Timber.d("Message search found ${results.size} items, pagination state: $paginationState") + // Try processing the results in parallel to speed up the processing time, since we have to wait until we get the room info for each // result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`. val mappedResults = results.map { result -> @@ -136,10 +150,19 @@ class GlobalSearchPresenter( .awaitAll() .mapNotNull { it } - searchResults = if (queryState.text.isNotEmpty() && mappedResults.isNotEmpty()) { - AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList())) - } else { - AsyncData.Uninitialized + searchResults = when { + mappedResults.isNotEmpty() -> { + AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList())) + } + paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> { + AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = persistentListOf())) + } + paginationState is MessageSearchPaginationState.Loading -> { + AsyncData.Loading(prevData = searchResults.dataOrNull()) + } + else -> { + AsyncData.Uninitialized + } } } } From b29a4c06d715be764e7400cfaa78762723ac8ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 12:43:09 +0200 Subject: [PATCH 08/17] Improve previews --- .../home/impl/roomlist/RoomListStateProvider.kt | 5 ++++- .../home/impl/search/GlobalSearchStateProvider.kt | 13 ++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt index 034e7acd42..f5f6d6a4a8 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt @@ -43,7 +43,10 @@ open class RoomListStateProvider : PreviewParameterProvider { aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)), aRoomListState(contentState = anEmptyContentState()), aRoomListState(contentState = aSkeletonContentState()), - aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")), + aRoomListState( + searchState = aRoomListSearchState(isSearchActive = true, query = "Test"), + globalSearchState = aGlobalSearchState(isEnabled = false), + ), aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)), aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))), aRoomListState(contentState = anEmptyContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)), diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt index c4343b8982..29031837f2 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStateProvider.kt @@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsRead import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList class GlobalSearchStateProvider : PreviewParameterProvider { override val values: Sequence @@ -56,7 +57,13 @@ class GlobalSearchStateProvider : PreviewParameterProvider { isSearchActive = true, currentTarget = GlobalSearchTarget.ROOMS, queryState = TextFieldState("Query"), - results = AsyncData.Success(GlobalSearchResults.RoomListResults(aRoomListRoomSummaryList())), + results = AsyncData.Success( + GlobalSearchResults.RoomListResults( + results = aRoomListRoomSummaryList().mapIndexed { index, summary -> + summary.copy(name = "Room with Query #${index + 1}") + }.toPersistentList() + ) + ), ), aGlobalSearchState( isSearchActive = true, @@ -65,7 +72,7 @@ class GlobalSearchStateProvider : PreviewParameterProvider { results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf( MessageSearchResultItem.Message( messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")), - body = "A message", + body = "A message with Query", roomInfo = aRoomInfo(), formattedTimestamp = "12:00", ), @@ -74,7 +81,7 @@ class GlobalSearchStateProvider : PreviewParameterProvider { mediaContent = MediaSearchResultContent( filename = "file.png", extension = "PNG", - caption = "A caption", + caption = "A caption containing Query", formattedSize = "1 MB", thumbnailSource = MediaSource("https://example.com/thumbnail.png"), thumbnailType = AttachmentThumbnailType.Image, From d05978cc16f33d6b701227b18c2c64f52b18e671 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 21 Aug 2026 11:00:17 +0000 Subject: [PATCH 09/17] Update screenshots --- .../features.home.impl.search_GlobalSearchView_Day_3_en.png | 4 ++-- .../features.home.impl.search_GlobalSearchView_Day_4_en.png | 4 ++-- .../features.home.impl.search_GlobalSearchView_Night_3_en.png | 4 ++-- .../features.home.impl.search_GlobalSearchView_Night_4_en.png | 4 ++-- .../images/features.home.impl_HomeView_Day_12_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_12_en.png | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png index 0a3484d59e..25a66543da 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c1c9b463f6fa8fa8c1e9c8a776734879201f506f4cad37ecdba05baabe49b1b3 -size 26668 +oid sha256:18498dbff28491dd108900453eab13d8224aa7960eefbe25993dcc82198677dd +size 35825 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png index a8bf17e259..be7992706c 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b58ab253d632a15dd5274351ec47dd293099a3fbf7dcac16103d8e63423b09b9 -size 73864 +oid sha256:346946f668bf18908480bf6b97158adcd7d9099d4be111055412ac2d4b7094c9 +size 77330 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png index aa0fd55433..8dc241988a 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:20701c8eca52f816c18c788f6ecef485255e3059b5790fc1705c52ede5d75566 -size 26708 +oid sha256:a7d01b051fda7a3f6dc7736bdbd631bd2e0f0cef61dbd9c973b48cee1e69d56c +size 35624 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png index 004b62009e..8e618f0070 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.search_GlobalSearchView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:07388f4fed1ecfd51aa2c98c0014b08aee983b50e691edef95c9d290c2c948aa -size 72281 +oid sha256:6637c79e684bf350204756a93679163fb17b8cf5f54c835fe81464d644edc3ff +size 75840 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png index b5f80f2faf..11ffe943aa 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:7a1e3fcf68d17303f7082f2f0ee124a166778bad097a9e9e49e4a2de3c63c506 +size 5700 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png index fe171e26b7..f57f515cee 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:aa9cd96bd8827121d24abaa44055485e34d5f8f3ee24c845a46247a094ca5b83 +size 5566 From 76b6e5857f105916b1f905c0965428d4a6e7ac5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 12:57:33 +0200 Subject: [PATCH 10/17] Fix lint issues --- .../features/home/impl/roomlist/RoomListPresenterTest.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index 470aaaa441..e82fa70052 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -18,7 +18,6 @@ import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFact import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.aRoomListFiltersState import io.element.android.features.home.impl.model.createRoomListRoomSummary -import io.element.android.features.home.impl.search.GlobalSearchPresenter import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.search.aRoomListSearchState @@ -82,7 +81,6 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first From e4891abf45cb59ac7a3062dcd7244983d327dbdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 13:03:06 +0200 Subject: [PATCH 11/17] `GlobalSearchPresenter` doesn't need `@ContributesBinding` --- .../android/features/home/impl/roomlist/RoomListPresenter.kt | 4 ++-- .../features/home/impl/search/GlobalSearchPresenter.kt | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 1c21177988..7b19fee8ef 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -30,7 +30,7 @@ import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.into -import io.element.android.features.home.impl.search.GlobalSearchState +import io.element.android.features.home.impl.search.GlobalSearchPresenter import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState @@ -86,7 +86,7 @@ class RoomListPresenter( private val announcementService: AnnouncementService, private val coldStartWatcher: AnalyticsColdStartWatcher, private val spaceFiltersPresenter: Presenter, - private val globalSearchPresenter: Presenter, + private val globalSearchPresenter: GlobalSearchPresenter, private val featureFlagService: FeatureFlagService, ) : Presenter { private val encryptionService = client.encryptionService diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index aeee0ddfeb..b7ab71cee2 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -17,14 +17,13 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode -import io.element.android.libraries.di.SessionScope import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags @@ -60,7 +59,7 @@ import timber.log.Timber import kotlin.jvm.optionals.getOrElse import kotlin.time.Duration.Companion.milliseconds -@ContributesBinding(SessionScope::class) +@Inject class GlobalSearchPresenter( private val roomListSearchDataSourceFactory: RoomListSearchDataSource.Factory, private val messageSearchService: MessageSearchService, From 679d97cf4f9ea818f6c9fa6874ba791044f84124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 15:15:48 +0200 Subject: [PATCH 12/17] Fix konsist issue about presenters not depending in other concrete presenter implementations --- .../element/android/features/home/impl/di/RoomListModule.kt | 4 ++++ .../features/home/impl/roomlist/RoomListPresenter.kt | 4 ++-- .../features/home/impl/search/RoomListSearchDataSource.kt | 2 ++ .../features/home/impl/roomlist/RoomListPresenterTest.kt | 6 ++---- .../features/home/impl/search/GlobalSearchPresenterTest.kt | 2 +- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt index 1eeba4fff7..8993c19480 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt @@ -15,6 +15,7 @@ import io.element.android.features.home.impl.filters.RoomListFiltersPresenter import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.roomlist.RoomListPresenter import io.element.android.features.home.impl.roomlist.RoomListState +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchPresenter import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersPresenter @@ -36,4 +37,7 @@ interface RoomListModule { @Binds fun bindSpaceFiltersPresenter(presenter: SpaceFiltersPresenter): Presenter + + @Binds + fun bindGlobalSearchPresenter(presenter: RoomListSearchPresenter): Presenter } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 7b19fee8ef..1c21177988 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -30,7 +30,7 @@ import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.into -import io.element.android.features.home.impl.search.GlobalSearchPresenter +import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState @@ -86,7 +86,7 @@ class RoomListPresenter( private val announcementService: AnnouncementService, private val coldStartWatcher: AnalyticsColdStartWatcher, private val spaceFiltersPresenter: Presenter, - private val globalSearchPresenter: GlobalSearchPresenter, + private val globalSearchPresenter: Presenter, private val featureFlagService: FeatureFlagService, ) : Presenter { private val encryptionService = client.encryptionService diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt index 03fcb5520f..df7ed0dd37 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt @@ -46,6 +46,8 @@ class RoomListSearchDataSource( coroutineScope = coroutineScope ) + val loadingState = roomList.loadingState + val roomSummaries: Flow> = roomList.summaries .map { roomSummaries -> roomSummaries diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index e82fa70052..d2e33c77c1 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -20,8 +20,8 @@ import io.element.android.features.home.impl.filters.aRoomListFiltersState import io.element.android.features.home.impl.model.createRoomListRoomSummary import io.element.android.features.home.impl.search.RoomListSearchEvent import io.element.android.features.home.impl.search.RoomListSearchState +import io.element.android.features.home.impl.search.aGlobalSearchState import io.element.android.features.home.impl.search.aRoomListSearchState -import io.element.android.features.home.impl.search.createGlobalSearchPresenter import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.aDisabledSpaceFiltersState import io.element.android.features.invite.api.SeenInvitesStore @@ -681,9 +681,7 @@ class RoomListPresenterTest { analyticsService = FakeAnalyticsService(), ), searchPresenter = searchPresenter, - globalSearchPresenter = createGlobalSearchPresenter( - matrixClient = client, - ), + globalSearchPresenter = { aGlobalSearchState() }, filtersPresenter = filtersPresenter, spaceFiltersPresenter = spaceFiltersPresenter, analyticsService = analyticsService, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt index 3f5fe802f9..cf28adb53b 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -202,7 +202,7 @@ class GlobalSearchPresenterTest { } } -internal fun TestScope.createGlobalSearchPresenter( +private fun TestScope.createGlobalSearchPresenter( roomListService: RoomListService = FakeRoomListService(), messageSearchService: MessageSearchService = FakeMessageSearchService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(), From c392737765e074a94a3ffe32a347360847eff8e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 15:16:26 +0200 Subject: [PATCH 13/17] Try fixing the empty state for the room list search in global search --- .../home/impl/search/GlobalSearchPresenter.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index b7ab71cee2..103548daea 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.roomlist.LatestEventValue +import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.search.MessageSearch import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState import io.element.android.libraries.matrix.api.search.MessageSearchResult @@ -108,13 +109,25 @@ class GlobalSearchPresenter( when (currentTarget) { GlobalSearchTarget.ROOMS -> { - roomListSearchDataSource.roomSummaries.collectLatest { results -> + combine( + roomListSearchDataSource.roomSummaries, + roomListSearchDataSource.loadingState, + ) { summaries, loadingState -> + Pair(summaries, loadingState) + }.collectLatest { (results, loadingState) -> + Timber.d("Room list search found ${results.size} items, loading state: $loadingState") // The room list data source always returns some values, even if the query is empty, // so we only want to update the search results if the query is not empty - searchResults = if (queryState.text.isNotEmpty() && results.isNotEmpty()) { - AsyncData.Success(GlobalSearchResults.RoomListResults(results = results)) - } else { - AsyncData.Uninitialized + searchResults = when { + queryState.text.isNotEmpty() && results.isNotEmpty() -> { + AsyncData.Success(GlobalSearchResults.RoomListResults(results = results)) + } + loadingState is RoomList.LoadingState.Loaded && results.isEmpty() -> { + AsyncData.Success(GlobalSearchResults.RoomListResults(results = persistentListOf())) + } + else -> { + AsyncData.Uninitialized + } } } } From 9935f91bc755776f0998d5524035455812a85a83 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Aug 2026 15:42:20 +0200 Subject: [PATCH 14/17] Fix wrong binding --- .../io/element/android/features/home/impl/di/RoomListModule.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt index 8993c19480..6b744ca4df 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt @@ -15,6 +15,7 @@ import io.element.android.features.home.impl.filters.RoomListFiltersPresenter import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.roomlist.RoomListPresenter import io.element.android.features.home.impl.roomlist.RoomListState +import io.element.android.features.home.impl.search.GlobalSearchPresenter import io.element.android.features.home.impl.search.GlobalSearchState import io.element.android.features.home.impl.search.RoomListSearchPresenter import io.element.android.features.home.impl.search.RoomListSearchState @@ -39,5 +40,5 @@ interface RoomListModule { fun bindSpaceFiltersPresenter(presenter: SpaceFiltersPresenter): Presenter @Binds - fun bindGlobalSearchPresenter(presenter: RoomListSearchPresenter): Presenter + fun bindGlobalSearchPresenter(presenter: GlobalSearchPresenter): Presenter } From 0a624c155009d7be00456c95f65fefbff2c7a0cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 15:51:14 +0200 Subject: [PATCH 15/17] Fix empty state for room search --- .../home/impl/search/GlobalSearchPresenter.kt | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index 103548daea..f0f3cac4d5 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -31,7 +31,6 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.roomlist.LatestEventValue -import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.search.MessageSearch import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState import io.element.android.libraries.matrix.api.search.MessageSearchResult @@ -109,25 +108,14 @@ class GlobalSearchPresenter( when (currentTarget) { GlobalSearchTarget.ROOMS -> { - combine( - roomListSearchDataSource.roomSummaries, - roomListSearchDataSource.loadingState, - ) { summaries, loadingState -> - Pair(summaries, loadingState) - }.collectLatest { (results, loadingState) -> - Timber.d("Room list search found ${results.size} items, loading state: $loadingState") + roomListSearchDataSource.roomSummaries.collectLatest { results -> + Timber.d("Room list search found ${results.size} items") // The room list data source always returns some values, even if the query is empty, // so we only want to update the search results if the query is not empty - searchResults = when { - queryState.text.isNotEmpty() && results.isNotEmpty() -> { - AsyncData.Success(GlobalSearchResults.RoomListResults(results = results)) - } - loadingState is RoomList.LoadingState.Loaded && results.isEmpty() -> { - AsyncData.Success(GlobalSearchResults.RoomListResults(results = persistentListOf())) - } - else -> { - AsyncData.Uninitialized - } + searchResults = if (queryState.text.isNotEmpty()) { + AsyncData.Success(GlobalSearchResults.RoomListResults(results = results)) + } else { + AsyncData.Uninitialized } } } From e3b72446cc0c6a86f6c9f2dd130ae9191ad9dabe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 16:10:44 +0200 Subject: [PATCH 16/17] Fix displaying the 'ready to search' state for message search after deleting the query --- .../android/features/home/impl/search/GlobalSearchPresenter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index f0f3cac4d5..24b2d2fb87 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -154,7 +154,7 @@ class GlobalSearchPresenter( mappedResults.isNotEmpty() -> { AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList())) } - paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> { + queryState.text.isNotEmpty() && paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> { AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = persistentListOf())) } paginationState is MessageSearchPaginationState.Loading -> { From b52ed580b134771678be365e420fd337adcf231a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 21 Aug 2026 16:19:36 +0200 Subject: [PATCH 17/17] Add more tests --- .../home/impl/search/GlobalSearchPresenter.kt | 3 +- .../impl/search/GlobalSearchPresenterTest.kt | 72 ++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index 24b2d2fb87..6e153344c9 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -151,10 +151,11 @@ class GlobalSearchPresenter( .mapNotNull { it } searchResults = when { + queryState.text.isEmpty() -> AsyncData.Uninitialized mappedResults.isNotEmpty() -> { AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList())) } - queryState.text.isNotEmpty() && paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> { + paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> { AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = persistentListOf())) } paginationState is MessageSearchPaginationState.Loading -> { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt index cf28adb53b..ef8d373630 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -139,13 +139,48 @@ class GlobalSearchPresenterTest { testScheduler.advanceUntilIdle() roomList.summaries.emit(listOf(aRoomSummary())) - val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() + // Wait for the presenter to emit a loading state before the success state + consumeItemsUntilPredicate { it.results is AsyncData.Loading } + + val successState = awaitItem() val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results assertThat(results).hasSize(1) cancelAndIgnoreRemainingEvents() } } + @Test + fun `present - room search displays the uninitialized state when removing the query`() = runTest { + val roomList = FakeDynamicRoomList() + val roomListService = FakeRoomListService(createRoomListLambda = { roomList }) + val presenter = createGlobalSearchPresenter(roomListService = roomListService) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.results).isEqualTo(AsyncData.Uninitialized) + + initialState.queryState.edit { append("A query") } + // Let the query be propagated to the data source before emitting results + testScheduler.advanceUntilIdle() + roomList.summaries.emit(listOf(aRoomSummary())) + + // Wait for the presenter to emit a loading state before the success state + consumeItemsUntilPredicate { it.results is AsyncData.Loading } + + val successState = awaitItem() + val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results + assertThat(results).hasSize(1) + + // Remove the query + successState.queryState.edit { replace(0, length, "") } + // Skip the intermediate state where the query is empty but results are still present + skipItems(1) + // It's back to the uninitialized state + assertThat(awaitItem().results).isInstanceOf(AsyncData.Uninitialized::class.java) + // This happens even if for some reason the room results still contained any items + assertThat(roomList.summaries.value).isNotEmpty() + } + } + @Test fun `present - message search results are mapped when the message search emits`() = runTest { val messageSearch = FakeMessageSearch() @@ -173,6 +208,41 @@ class GlobalSearchPresenterTest { } } + @Test + fun `present - message search displays the uninitialized state after removing the query`() = runTest { + val messageSearch = FakeMessageSearch() + val matrixClient = FakeMatrixClient().apply { + getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } + } + val presenter = createGlobalSearchPresenter( + messageSearchService = FakeMessageSearchService(messageSearch), + matrixClient = matrixClient, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) + initialState.queryState.edit { append("A query") } + testScheduler.advanceUntilIdle() + + messageSearch.emitResults(persistentListOf(aMessageSearchResult())) + testScheduler.advanceUntilIdle() + + val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() + val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results + assertThat(results).hasSize(1) + assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) + + // Remove the query + successState.queryState.edit { replace(0, length, "") } + // Skip the intermediate state where the query is empty but results are still present + skipItems(1) + // It's back to the uninitialized state + assertThat(awaitItem().results).isInstanceOf(AsyncData.Uninitialized::class.java) + // This happens even if for some reason the message results still contained any items + assertThat(messageSearch.results.value).isNotEmpty() + } + } + @Test fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest { val messageSearch = FakeMessageSearch()