mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 12:54:35 +00:00
Merge pull request #7410 from element-hq/feat/global-search-screen
Feature: global search
This commit is contained in:
+2
-1
@@ -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()
|
||||
|
||||
+5
-3
@@ -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
|
||||
)
|
||||
|
||||
+41
-16
@@ -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 = {},
|
||||
|
||||
+5
@@ -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>
|
||||
}
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+8
-1
@@ -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,
|
||||
|
||||
+18
@@ -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
|
||||
}
|
||||
+301
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+92
@@ -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?,
|
||||
)
|
||||
+249
@@ -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
|
||||
)
|
||||
+564
@@ -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 = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
@@ -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>
|
||||
+2
-1
@@ -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()
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+305
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user