mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Add recent search results to global search (#7742)
* Add global search results history: keep the latest 25 items used in the global search, save them to an encrypted file store. * Integrate the search history in the global search feature * Add `MatrixClient.getRoomInfo` and use it in `GlobalSearchPresenter` --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
committed by
GitHub
parent
51fe66cd88
commit
8b1b5560d9
+18
@@ -7,9 +7,27 @@
|
||||
|
||||
package io.element.android.features.home.impl.search
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
sealed interface GlobalSearchEvent {
|
||||
/** The user cleared the search query. */
|
||||
object ClearQuery : GlobalSearchEvent
|
||||
|
||||
/** The search screen visibility changed (e.g. the user opened or closed the search screen). */
|
||||
object ToggleSearchVisibility : GlobalSearchEvent
|
||||
|
||||
/** The visible range for the displayed search results changed. */
|
||||
data class UpdateVisibleRange(val range: IntRange) : GlobalSearchEvent
|
||||
|
||||
/** The user changed the search target (e.g. from "Rooms" to "Messages"). */
|
||||
data class UpdateTarget(val target: GlobalSearchTarget) : GlobalSearchEvent
|
||||
|
||||
/** The user triggered the keyboard search action (enter key): save the current query to the search history. */
|
||||
object SaveQueryToHistory : GlobalSearchEvent
|
||||
|
||||
/** The user opened a room from the search results: save it to the search history. */
|
||||
data class SaveRoomToHistory(val roomId: RoomId) : GlobalSearchEvent
|
||||
|
||||
/** The user selected a search result from the search history. */
|
||||
data class SearchHistoryResultSelected(val resultItem: SearchHistoryResultItem) : GlobalSearchEvent
|
||||
}
|
||||
|
||||
+47
-4
@@ -18,6 +18,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.home.impl.search.history.SearchHistoryResult
|
||||
import io.element.android.features.home.impl.search.history.SearchHistoryStore
|
||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
@@ -47,6 +49,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageT
|
||||
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.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.async
|
||||
@@ -54,10 +57,8 @@ 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
|
||||
@@ -71,6 +72,7 @@ class GlobalSearchPresenter(
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val searchHistoryStore: SearchHistoryStore,
|
||||
) : Presenter<GlobalSearchState> {
|
||||
@Composable
|
||||
override fun present(): GlobalSearchState {
|
||||
@@ -85,6 +87,21 @@ class GlobalSearchPresenter(
|
||||
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
|
||||
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
|
||||
|
||||
val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryResultItem>>>(initialValue = AsyncData.Uninitialized) {
|
||||
searchHistoryStore.history.collectLatest { history ->
|
||||
val mappedHistoryItems = history.mapNotNull { result ->
|
||||
when (result) {
|
||||
is SearchHistoryResult.Query -> SearchHistoryResultItem.Query(term = result.term)
|
||||
is SearchHistoryResult.Room -> {
|
||||
val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@mapNotNull null
|
||||
SearchHistoryResultItem.Room(roomId = result.roomId, roomInfo = roomInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
value = AsyncData.Success(mappedHistoryItems.toImmutableList())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -141,7 +158,7 @@ class GlobalSearchPresenter(
|
||||
// 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 roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@async null
|
||||
val formattedTimestamp = dateFormatter.format(
|
||||
timestamp = result.timestamp,
|
||||
mode = DateFormatterMode.TimeOrDate,
|
||||
@@ -180,7 +197,8 @@ class GlobalSearchPresenter(
|
||||
|
||||
fun handleEvent(event: GlobalSearchEvent) {
|
||||
when (event) {
|
||||
GlobalSearchEvent.ClearQuery -> {
|
||||
GlobalSearchEvent.ClearQuery -> coroutineScope.launch {
|
||||
searchResults = AsyncData.Uninitialized
|
||||
queryState.clearText()
|
||||
}
|
||||
GlobalSearchEvent.ToggleSearchVisibility -> {
|
||||
@@ -205,6 +223,30 @@ class GlobalSearchPresenter(
|
||||
}
|
||||
}
|
||||
is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target
|
||||
GlobalSearchEvent.SaveQueryToHistory -> {
|
||||
val term = queryState.text.toString()
|
||||
if (term.isNotBlank()) {
|
||||
coroutineScope.launch {
|
||||
searchHistoryStore.add(SearchHistoryResult.Query(term))
|
||||
}
|
||||
}
|
||||
}
|
||||
is GlobalSearchEvent.SaveRoomToHistory -> coroutineScope.launch {
|
||||
searchHistoryStore.add(SearchHistoryResult.Room(event.roomId))
|
||||
}
|
||||
is GlobalSearchEvent.SearchHistoryResultSelected -> {
|
||||
when (event.resultItem) {
|
||||
is SearchHistoryResultItem.Query -> coroutineScope.launch {
|
||||
delay(300.milliseconds)
|
||||
queryState.edit {
|
||||
replace(0, length, event.resultItem.term)
|
||||
}
|
||||
searchResults = AsyncData.Loading()
|
||||
currentTarget = GlobalSearchTarget.ROOMS
|
||||
}
|
||||
is SearchHistoryResultItem.Room -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +256,7 @@ class GlobalSearchPresenter(
|
||||
queryState = queryState,
|
||||
currentTarget = currentTarget,
|
||||
results = searchResults,
|
||||
history = searchHistory,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+15
@@ -28,6 +28,7 @@ data class GlobalSearchState(
|
||||
val queryState: TextFieldState,
|
||||
val currentTarget: GlobalSearchTarget,
|
||||
val results: AsyncData<GlobalSearchResults>,
|
||||
val history: AsyncData<ImmutableList<SearchHistoryResultItem>>,
|
||||
val eventSink: (GlobalSearchEvent) -> Unit
|
||||
)
|
||||
|
||||
@@ -81,6 +82,20 @@ sealed interface MessageSearchResultItem {
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface SearchHistoryResultItem {
|
||||
val id: String
|
||||
|
||||
data class Query(val term: String) : SearchHistoryResultItem {
|
||||
override val id: String = "query:$term"
|
||||
}
|
||||
data class Room(
|
||||
val roomId: RoomId,
|
||||
val roomInfo: RoomInfo,
|
||||
) : SearchHistoryResultItem {
|
||||
override val id: String = "room:${roomId.value}"
|
||||
}
|
||||
}
|
||||
|
||||
data class MediaSearchResultContent(
|
||||
val filename: String,
|
||||
val extension: String?,
|
||||
|
||||
+121
-57
@@ -36,77 +36,139 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
|
||||
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.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
private val tombstonedRoomId = RoomId("!tombstone:server.org")
|
||||
private val roomId1 = RoomId("!roomId:server.org")
|
||||
private val roomId2 = RoomId("!roomId2:server.org")
|
||||
private val dmRoomId = RoomId("!dm:server.org")
|
||||
private const val AN_AVATAR_URL = "https://example.com/avatar.png"
|
||||
private val aCanonicalAlias = RoomAlias("#alias:server.org")
|
||||
|
||||
class GlobalSearchStatePreviewParam : 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()
|
||||
get() {
|
||||
return 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())),
|
||||
),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.ROOMS,
|
||||
queryState = TextFieldState(),
|
||||
results = AsyncData.Uninitialized,
|
||||
history = AsyncData.Success(
|
||||
persistentListOf(
|
||||
SearchHistoryResultItem.Query("Query 1"),
|
||||
SearchHistoryResultItem.Query("Query 2"),
|
||||
SearchHistoryResultItem.Room(
|
||||
roomId = roomId1,
|
||||
roomInfo = aRoomInfo(
|
||||
id = roomId1,
|
||||
name = "A room with alias",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canonicalAlias = aCanonicalAlias,
|
||||
)
|
||||
),
|
||||
SearchHistoryResultItem.Room(
|
||||
roomId = roomId2,
|
||||
roomInfo = aRoomInfo(
|
||||
id = roomId2,
|
||||
name = "A room",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
)
|
||||
),
|
||||
SearchHistoryResultItem.Room(
|
||||
roomId = dmRoomId,
|
||||
roomInfo = aRoomInfo(
|
||||
id = dmRoomId,
|
||||
name = "A DM",
|
||||
isDm = true,
|
||||
heroes = listOf(MatrixUser(UserId("@user:server.org"), "User", AN_AVATAR_URL)),
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canonicalAlias = aCanonicalAlias,
|
||||
)
|
||||
),
|
||||
SearchHistoryResultItem.Room(
|
||||
roomId = tombstonedRoomId,
|
||||
roomInfo = aRoomInfo(
|
||||
id = tombstonedRoomId,
|
||||
name = "A tombstoned room",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
successorRoom = SuccessorRoom(
|
||||
roomId = RoomId("!successorRoom:server.org"),
|
||||
reason = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
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"),
|
||||
id: RoomId = roomId1,
|
||||
name: String? = "A room",
|
||||
rawName: String? = "A room raw name",
|
||||
topic: String? = "A room topic",
|
||||
avatarUrl: String? = "https://example.com/avatar.png",
|
||||
avatarUrl: String? = AN_AVATAR_URL,
|
||||
isPublic: Boolean = true,
|
||||
isDirect: Boolean = false,
|
||||
isEncrypted: Boolean = false,
|
||||
@@ -217,7 +279,7 @@ fun aMessageContent(
|
||||
)
|
||||
|
||||
internal fun aMessageSearchResult(
|
||||
roomId: RoomId = RoomId("!roomId:server.org"),
|
||||
roomId: RoomId = roomId1,
|
||||
eventId: EventId = EventId("\$eventId:server.org"),
|
||||
senderId: UserId = UserId("@user:server.org"),
|
||||
senderProfile: ProfileDetails = aProfileDetailsReady(),
|
||||
@@ -238,6 +300,7 @@ internal fun aGlobalSearchState(
|
||||
queryState: TextFieldState = TextFieldState(),
|
||||
currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
|
||||
results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized,
|
||||
history: AsyncData<ImmutableList<SearchHistoryResultItem>> = AsyncData.Uninitialized,
|
||||
eventSink: (GlobalSearchEvent) -> Unit = {},
|
||||
) = GlobalSearchState(
|
||||
isEnabled = isEnabled,
|
||||
@@ -245,5 +308,6 @@ internal fun aGlobalSearchState(
|
||||
queryState = queryState,
|
||||
currentTarget = currentTarget,
|
||||
results = results,
|
||||
history = history,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
+204
-78
@@ -23,11 +23,15 @@ 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.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.KeyboardActionHandler
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -44,6 +48,7 @@ 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.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -63,6 +68,7 @@ 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.HorizontalDivider
|
||||
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
|
||||
@@ -80,6 +86,7 @@ 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.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Composable
|
||||
@@ -103,7 +110,10 @@ fun GlobalSearchView(
|
||||
onBackButtonClick = {
|
||||
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
|
||||
},
|
||||
onSelectSearchResult = onSelectSearchResult,
|
||||
onSelectSearchResult = { roomId, eventId ->
|
||||
onSelectSearchResult(roomId, eventId)
|
||||
state.eventSink(GlobalSearchEvent.SaveRoomToHistory(roomId))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -115,63 +125,11 @@ private fun GlobalSearchContent(
|
||||
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()
|
||||
}
|
||||
}
|
||||
title = { SearchTopAppBarContent(state = state) }
|
||||
)
|
||||
},
|
||||
contentWindowInsets = scaffoldScrollableContentInsets,
|
||||
@@ -179,27 +137,9 @@ private fun GlobalSearchContent(
|
||||
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),
|
||||
)
|
||||
HorizontalDivider(color = ElementTheme.colors.borderInteractivePrimary)
|
||||
|
||||
SegmentedButton(
|
||||
index = 1,
|
||||
count = 2,
|
||||
selected = state.currentTarget == GlobalSearchTarget.MESSAGES,
|
||||
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) },
|
||||
text = stringResource(R.string.search_section_messages),
|
||||
)
|
||||
}
|
||||
SearchTargetSelector(state = state)
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
|
||||
@@ -212,9 +152,21 @@ private fun GlobalSearchContent(
|
||||
state = lazyListState,
|
||||
) {
|
||||
val results = state.results.dataOrNull()
|
||||
val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf()
|
||||
when {
|
||||
state.results.isUninitialized() -> startSearching()
|
||||
state.results.isLoading() -> loading()
|
||||
state.results.isUninitialized() -> if (state.queryState.text.isBlank() && historyResults.isNotEmpty()) {
|
||||
searchHistory(historyResults) { result ->
|
||||
when (result) {
|
||||
is SearchHistoryResultItem.Query -> Unit
|
||||
is SearchHistoryResultItem.Room -> onSelectSearchResult(result.roomInfo.id, null)
|
||||
}
|
||||
|
||||
state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result))
|
||||
}
|
||||
} else {
|
||||
startSearching()
|
||||
}
|
||||
state.results.isLoading() -> loadingResults()
|
||||
results?.isEmpty() == true -> emptySearchResults(query = state.queryState.text.toString())
|
||||
results is GlobalSearchResults.RoomListResults -> roomListResults(
|
||||
results = results.results,
|
||||
@@ -230,6 +182,89 @@ private fun GlobalSearchContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchTopAppBarContent(state: GlobalSearchState) {
|
||||
// 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,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActionHandler { performDefault ->
|
||||
state.eventSink(GlobalSearchEvent.SaveQueryToHistory)
|
||||
performDefault()
|
||||
},
|
||||
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(
|
||||
modifier = Modifier.clip(CircleShape).size(20.dp),
|
||||
onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) },
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = ElementTheme.colors.borderInteractivePrimary,
|
||||
contentColor = ElementTheme.colors.iconOnSolidPrimary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(16.dp),
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.a11y_clear_search_field),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!focusRequester.restoreFocusedChild()) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
focusRequester.saveFocusedChild()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchTargetSelector(state: GlobalSearchState) {
|
||||
if (state.queryState.text.isEmpty()) return
|
||||
|
||||
SingleChoiceSegmentedButtonRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.startSearching() {
|
||||
item {
|
||||
IconTitleSubtitleMolecule(
|
||||
@@ -243,7 +278,98 @@ private fun LazyListScope.startSearching() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.loading() {
|
||||
private fun LazyListScope.searchHistory(
|
||||
historyResults: ImmutableList<SearchHistoryResultItem>,
|
||||
onSelectSearchHistoryResult: (SearchHistoryResultItem) -> Unit,
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
|
||||
text = stringResource(R.string.screen_search_recent_searches_section_title),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
items(
|
||||
items = historyResults,
|
||||
key = { it.id },
|
||||
) { result ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.niceClickable(onClick = { onSelectSearchHistoryResult(result) })
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (result) {
|
||||
is SearchHistoryResultItem.Query -> {
|
||||
Icon(
|
||||
modifier = Modifier.size(32.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(ElementTheme.colors.bgSubtleSecondary)
|
||||
.padding(6.dp),
|
||||
imageVector = CompoundIcons.History(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconPrimary,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = result.term,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
}
|
||||
is SearchHistoryResultItem.Room -> {
|
||||
val heroAvatars = remember(result.roomInfo.heroes) {
|
||||
result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.SearchRoomListItem) }.toImmutableList()
|
||||
}
|
||||
Avatar(
|
||||
avatarData = result.roomInfo.getAvatarData(AvatarSize.SearchRoomListItem),
|
||||
avatarType = AvatarType.Room(
|
||||
heroes = heroAvatars,
|
||||
isTombstoned = result.roomInfo.successorRoom != null,
|
||||
),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = result.roomInfo.name.orEmpty(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
|
||||
if (result.roomInfo.isDm) {
|
||||
val dmUserId = result.roomInfo.heroes.firstOrNull()?.userId
|
||||
dmUserId?.let { userId ->
|
||||
Text(
|
||||
text = userId.value,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
} else if (result.roomInfo.canonicalAlias != null) {
|
||||
Text(
|
||||
text = result.roomInfo.canonicalAlias?.value!!,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.loadingResults() {
|
||||
item {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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.history
|
||||
|
||||
import android.content.Context
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.encrypteddb.crypto.EncryptedFile
|
||||
import java.io.File
|
||||
|
||||
private const val SEARCH_HISTORY_FILE_NAME = "search_history"
|
||||
|
||||
/**
|
||||
* Persists the search history in an encrypted file inside the current session file directory, using
|
||||
* [EncryptedFile] (Tink StreamingAead + a master key stored in the Android Keystore).
|
||||
*/
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultSearchHistoryFileStore(
|
||||
@ApplicationContext private val context: Context,
|
||||
matrixClient: MatrixClient,
|
||||
) : SearchHistoryFileStore {
|
||||
private val file: File by lazy {
|
||||
File(matrixClient.sessionPaths.fileDirectory, SEARCH_HISTORY_FILE_NAME)
|
||||
}
|
||||
|
||||
override fun read(): ByteArray? {
|
||||
if (!file.exists()) return null
|
||||
return EncryptedFile(context, file).openFileInput().use { it.readBytes() }
|
||||
}
|
||||
|
||||
override fun write(bytes: ByteArray) {
|
||||
file.parentFile?.takeIf { !it.exists() }?.mkdirs()
|
||||
// Opening a FileOutputStream truncates any previous content, so this overwrites the file.
|
||||
EncryptedFile(context, file).openFileOutput().use { it.write(bytes) }
|
||||
}
|
||||
|
||||
override fun delete(): Boolean {
|
||||
return file.delete()
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.history
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultSearchHistoryStore(
|
||||
private val fileStore: SearchHistoryFileStore,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val jsonProvider: JsonProvider,
|
||||
) : SearchHistoryStore {
|
||||
private val json by lazy { jsonProvider() }
|
||||
|
||||
// Guards [fileStore] access and the lazy initialization of [cache].
|
||||
private val mutex = Mutex()
|
||||
private val cache = MutableStateFlow<List<SearchHistoryResult>>(emptyList())
|
||||
private var isLoaded = false
|
||||
|
||||
override val history: Flow<List<SearchHistoryResult>> = flow {
|
||||
ensureLoaded()
|
||||
emitAll(cache)
|
||||
}
|
||||
|
||||
override suspend fun add(result: SearchHistoryResult) = withContext(dispatchers.io) {
|
||||
mutex.withLock {
|
||||
ensureLoadedLocked()
|
||||
val updated = buildList {
|
||||
add(result)
|
||||
// Deduplicate: an equal entry is moved to the front rather than being duplicated.
|
||||
addAll(cache.value.filterNot { it == result })
|
||||
}
|
||||
.distinct()
|
||||
.take(MAX_SEARCH_HISTORY_SIZE)
|
||||
cache.value = updated
|
||||
persist(updated)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() = withContext<Unit>(dispatchers.io) {
|
||||
mutex.withLock {
|
||||
isLoaded = true
|
||||
cache.value = emptyList()
|
||||
runCatchingExceptions { fileStore.delete() }
|
||||
.onFailure { Timber.e(it, "Failed to delete search history") }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureLoaded() = withContext(dispatchers.io) {
|
||||
mutex.withLock {
|
||||
ensureLoadedLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called while holding [mutex].
|
||||
*/
|
||||
private fun ensureLoadedLocked() {
|
||||
if (isLoaded) return
|
||||
cache.value = readFromDisk()
|
||||
isLoaded = true
|
||||
}
|
||||
|
||||
private fun readFromDisk(): List<SearchHistoryResult> {
|
||||
return runCatchingExceptions {
|
||||
val bytes = fileStore.read() ?: return@runCatchingExceptions emptyList()
|
||||
json.decodeFromString<List<SearchHistoryResult>>(bytes.decodeToString())
|
||||
}.getOrElse {
|
||||
Timber.e(it, "Failed to read search history, resetting it")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist(items: List<SearchHistoryResult>) {
|
||||
runCatchingExceptions {
|
||||
fileStore.write(json.encodeToString(items).encodeToByteArray())
|
||||
}.onFailure { Timber.e(it, "Failed to persist search history") }
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.history
|
||||
|
||||
/**
|
||||
* Low level, raw byte read/write of the persisted search history.
|
||||
*
|
||||
* This exists as a seam so that [DefaultSearchHistoryStore] can be unit tested without relying on
|
||||
* the Android Keystore. The production implementation ([DefaultSearchHistoryFileStore]) stores the
|
||||
* bytes in an encrypted file.
|
||||
*/
|
||||
interface SearchHistoryFileStore {
|
||||
/**
|
||||
* Return the persisted bytes, or `null` if nothing has been persisted yet.
|
||||
*/
|
||||
fun read(): ByteArray?
|
||||
|
||||
/**
|
||||
* Persist [bytes], replacing any previously persisted content.
|
||||
*/
|
||||
fun write(bytes: ByteArray)
|
||||
|
||||
/**
|
||||
* Delete any persisted content.
|
||||
*
|
||||
* @return `true` if the persisted content was deleted, `false` if there was no persisted content to delete.
|
||||
*/
|
||||
fun delete(): Boolean
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.history
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A single entry in the global search history.
|
||||
*
|
||||
* The history stores either the term used to perform a search (when the keyboard search action is
|
||||
* triggered) or the id of a room opened from the search results.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface SearchHistoryResult {
|
||||
@Serializable
|
||||
data class Query(val term: String) : SearchHistoryResult
|
||||
|
||||
@Serializable
|
||||
data class Room(val roomId: RoomId) : SearchHistoryResult
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.history
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Stores the global search history for the current session.
|
||||
*
|
||||
* The history is persisted in an encrypted file and capped to [MAX_SEARCH_HISTORY_SIZE] entries.
|
||||
* The most recent entry is always at the front of the list; when the cap is reached the oldest
|
||||
* entries are dropped.
|
||||
*/
|
||||
interface SearchHistoryStore {
|
||||
/**
|
||||
* Emits the current search history, most recent entry first, and re-emits whenever it changes.
|
||||
*/
|
||||
val history: Flow<List<SearchHistoryResult>>
|
||||
|
||||
/**
|
||||
* Add a new [result] at the front of the history.
|
||||
*
|
||||
* If an equal entry already exists it is moved to the front instead of being duplicated. When
|
||||
* the history exceeds [MAX_SEARCH_HISTORY_SIZE] entries the oldest ones are dropped.
|
||||
*/
|
||||
suspend fun add(result: SearchHistoryResult)
|
||||
|
||||
/**
|
||||
* Remove every entry from the history.
|
||||
*/
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum number of entries kept in the search history.
|
||||
*/
|
||||
const val MAX_SEARCH_HISTORY_SIZE = 25
|
||||
@@ -14,4 +14,5 @@
|
||||
<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>
|
||||
<string name="screen_search_recent_searches_section_title">Recent searches</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user