mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 16:22:49 +00:00
Merge pull request #7410 from element-hq/feat/global-search-screen
Feature: global search
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+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,
|
||||
)
|
||||
}
|
||||
+2
@@ -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?,
|
||||
|
||||
+3
@@ -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),
|
||||
|
||||
+6
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
Reference in New Issue
Block a user