Merge pull request #7410 from element-hq/feat/global-search-screen

Feature: global search
This commit is contained in:
Benoit Marty
2026-08-24 09:34:35 +02:00
committed by GitHub
34 changed files with 1669 additions and 25 deletions
@@ -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,
)
}
@@ -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()
@@ -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
)
@@ -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 = {},
@@ -15,6 +15,8 @@ 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
import io.element.android.features.home.impl.spacefilters.SpaceFiltersPresenter
@@ -36,4 +38,7 @@ interface RoomListModule {
@Binds
fun bindSpaceFiltersPresenter(presenter: SpaceFiltersPresenter): Presenter<SpaceFiltersState>
@Binds
fun bindGlobalSearchPresenter(presenter: GlobalSearchPresenter): Presenter<GlobalSearchState>
}
@@ -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.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
@@ -85,6 +86,7 @@ class RoomListPresenter(
private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher,
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
private val globalSearchPresenter: Presenter<GlobalSearchState>,
private val featureFlagService: FeatureFlagService,
) : Presenter<RoomListState> {
private val encryptionService = client.encryptionService
@@ -97,6 +99,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 +180,7 @@ class RoomListPresenter(
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
globalSearchState = globalSearchState,
spaceFiltersState = spaceFiltersState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
@@ -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,
@@ -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
@@ -41,7 +43,10 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
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)),
@@ -53,6 +58,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 +72,7 @@ internal fun aRoomListState(
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
globalSearchState = globalSearchState,
spaceFiltersState = spaceFiltersState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
@@ -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,301 @@
/*
* 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.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.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.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
@Inject
class GlobalSearchPresenter(
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> {
@Composable
override fun present(): GlobalSearchState {
val coroutineScope = rememberCoroutineScope()
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
}
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) {
if (queryState.text.isNotEmpty()) {
searchResults = AsyncData.Loading()
}
when (currentTarget) {
GlobalSearchTarget.ROOMS -> {
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 = if (queryState.text.isNotEmpty()) {
AsyncData.Success(GlobalSearchResults.RoomListResults(results = results))
} else {
AsyncData.Uninitialized
}
}
}
GlobalSearchTarget.MESSAGES -> {
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 ->
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 = when {
queryState.text.isEmpty() -> AsyncData.Uninitialized
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
}
}
}
}
}
}
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,249 @@
/*
* 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
import kotlinx.collections.immutable.toPersistentList
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(
results = aRoomListRoomSummaryList().mapIndexed { index, summary ->
summary.copy(name = "Room with Query #${index + 1}")
}.toPersistentList()
)
),
),
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 with Query",
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
MessageSearchResultItem.Media(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
mediaContent = MediaSearchResultContent(
filename = "file.png",
extension = "PNG",
caption = "A caption containing Query",
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 = { _, _ -> },
)
}
}
@@ -46,6 +46,8 @@ class RoomListSearchDataSource(
coroutineScope = coroutineScope
)
val loadingState = roomList.loadingState
val roomSummaries: Flow<ImmutableList<RoomListRoomSummary>> = roomList.summaries
.map { roomSummaries ->
roomSummaries
@@ -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>
@@ -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()
@@ -20,6 +20,7 @@ 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.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.aDisabledSpaceFiltersState
@@ -680,6 +681,7 @@ class RoomListPresenterTest {
analyticsService = FakeAnalyticsService(),
),
searchPresenter = searchPresenter,
globalSearchPresenter = { aGlobalSearchState() },
filtersPresenter = filtersPresenter,
spaceFiltersPresenter = spaceFiltersPresenter,
analyticsService = analyticsService,
@@ -277,7 +277,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
setSafeContent {
HomeView(
homeState = aHomeState(roomListState = state),
onRoomClick = onRoomClick,
onRoomClick = { roomId, _ -> onRoomClick(roomId) },
onSettingsClick = onSettingsClick,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
@@ -0,0 +1,305 @@
/*
* 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()))
// 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()
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 - 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()
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()
}
}
}
private 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(),
): GlobalSearchPresenter {
return GlobalSearchPresenter(
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,
)
}
@@ -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?,
@@ -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),
@@ -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
}
}