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.
This commit is contained in:
Jorge Martín
2026-08-20 13:55:15 +02:00
parent 9d36c8c4ed
commit be2909bca2
8 changed files with 1221 additions and 0 deletions
@@ -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
}
@@ -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<GlobalSearchState> {
@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<GlobalSearchResults> 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,
)
}
}
@@ -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<GlobalSearchResults>,
val eventSink: (GlobalSearchEvent) -> Unit
)
@Immutable
enum class GlobalSearchTarget {
ROOMS,
MESSAGES,
}
@Immutable
sealed interface GlobalSearchResults {
data class RoomListResults(val results: ImmutableList<RoomListRoomSummary>) : GlobalSearchResults
data class MessageSearchResults(val results: ImmutableList<MessageSearchResultItem>) : 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?,
)
@@ -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<GlobalSearchState> {
override val values: Sequence<GlobalSearchState>
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<RoomAlias> = 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<UserId> = emptyList(),
heroes: List<MatrixUser> = emptyList(),
pinnedEventIds: List<EventId> = emptyList(),
roomCreators: List<UserId> = 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<GlobalSearchResults> = AsyncData.Uninitialized,
eventSink: (GlobalSearchEvent) -> Unit = {},
) = GlobalSearchState(
isEnabled = isEnabled,
isSearchActive = isSearchActive,
queryState = queryState,
currentTarget = currentTarget,
results = results,
eventSink = eventSink
)
@@ -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<RoomListRoomSummary>,
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<MessageSearchResultItem>,
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 = { _, _ -> },
)
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<resources>
<string name="start_searching_title">Start searching…</string>
<string name="start_searching_subtitle">Search for chats and messages</string>
<string name="search_section_chats">Chats</string>
<string name="search_from_your_messages_header">From your messages</string>
<string name="search_section_messages">Messages</string>
<string name="search_no_results_title">No results</string>
<string name="search_no_results_subtitle">There are no results for “%1$s”. Try a new search term.</string>
</resources>