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),