Add remove item and clear history buttons to search history (#7816)

* Add remove and clear history buttons to search

* Rename `SearchHistoryResultItem` to `SearchHistoryListItem` and `MessagesSearchResultItem` to `MessageSearchListItem`

Also, add some docs.

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-10-01 18:17:18 +02:00
committed by GitHub
parent d589ff37dc
commit 6cde0706be
26 changed files with 323 additions and 82 deletions
@@ -29,5 +29,11 @@ sealed interface GlobalSearchEvent {
data class SaveRoomToHistory(val roomId: RoomId) : GlobalSearchEvent data class SaveRoomToHistory(val roomId: RoomId) : GlobalSearchEvent
/** The user selected a search result from the search history. */ /** The user selected a search result from the search history. */
data class SearchHistoryResultSelected(val resultItem: SearchHistoryResultItem) : GlobalSearchEvent data class SearchHistoryResultSelected(val resultItem: SearchHistoryListItem) : GlobalSearchEvent
/** The user removed a search history entry. */
data class RemoveSearchHistoryResult(val result: SearchHistoryListItem) : GlobalSearchEvent
/** The user cleared the search history. */
data object ClearSearchHistory : GlobalSearchEvent
} }
@@ -57,6 +57,7 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -87,14 +88,14 @@ class GlobalSearchPresenter(
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) } var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) } val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryResultItem>>>(initialValue = AsyncData.Uninitialized) { val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryListItem>>>(initialValue = AsyncData.Uninitialized) {
searchHistoryStore.history.collectLatest { history -> searchHistoryStore.history.collectLatest { history ->
val mappedHistoryItems = history.mapNotNull { result -> val mappedHistoryItems = history.mapNotNull { result ->
when (result) { when (result) {
is SearchHistoryResult.Query -> SearchHistoryResultItem.Query(term = result.term) is SearchHistoryResult.Query -> SearchHistoryListItem.Query(term = result.term)
is SearchHistoryResult.Room -> { is SearchHistoryResult.Room -> {
val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@mapNotNull null val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@mapNotNull null
SearchHistoryResultItem.Room(roomId = result.roomId, roomInfo = roomInfo) SearchHistoryListItem.Room(roomId = result.roomId, roomInfo = roomInfo)
} }
} }
} }
@@ -236,7 +237,7 @@ class GlobalSearchPresenter(
} }
is GlobalSearchEvent.SearchHistoryResultSelected -> { is GlobalSearchEvent.SearchHistoryResultSelected -> {
when (event.resultItem) { when (event.resultItem) {
is SearchHistoryResultItem.Query -> coroutineScope.launch { is SearchHistoryListItem.Query -> coroutineScope.launch {
delay(300.milliseconds) delay(300.milliseconds)
queryState.edit { queryState.edit {
replace(0, length, event.resultItem.term) replace(0, length, event.resultItem.term)
@@ -244,9 +245,21 @@ class GlobalSearchPresenter(
searchResults = AsyncData.Loading() searchResults = AsyncData.Loading()
currentTarget = GlobalSearchTarget.ROOMS currentTarget = GlobalSearchTarget.ROOMS
} }
is SearchHistoryResultItem.Room -> Unit is SearchHistoryListItem.Room -> Unit
} }
} }
is GlobalSearchEvent.RemoveSearchHistoryResult -> coroutineScope.launch {
val currentHistory = searchHistoryStore.history.first()
val id = when (event.result) {
is SearchHistoryListItem.Query -> event.result.term
is SearchHistoryListItem.Room -> event.result.roomId.value
}
val result = currentHistory.find { it.id == id } ?: return@launch
searchHistoryStore.remove(result)
}
GlobalSearchEvent.ClearSearchHistory -> coroutineScope.launch {
searchHistoryStore.clear()
}
} }
} }
@@ -265,7 +278,7 @@ class GlobalSearchPresenter(
result: MessageSearchResult, result: MessageSearchResult,
roomInfo: RoomInfo, roomInfo: RoomInfo,
formattedTimestamp: String, formattedTimestamp: String,
): MessageSearchResultItem.Message? { ): MessageSearchListItem.Message? {
val body = latestEventFormatter.format( val body = latestEventFormatter.format(
latestEvent = LatestEventValue.Remote( latestEvent = LatestEventValue.Remote(
timestamp = result.timestamp, timestamp = result.timestamp,
@@ -276,7 +289,7 @@ class GlobalSearchPresenter(
), ),
isDmRoom = false, isDmRoom = false,
) )
return MessageSearchResultItem.Message( return MessageSearchListItem.Message(
messageSearchResult = result, messageSearchResult = result,
body = body?.toString() ?: "", body = body?.toString() ?: "",
formattedTimestamp = formattedTimestamp, formattedTimestamp = formattedTimestamp,
@@ -288,7 +301,7 @@ class GlobalSearchPresenter(
result: MessageSearchResult, result: MessageSearchResult,
roomInfo: RoomInfo, roomInfo: RoomInfo,
formattedTimestamp: String, formattedTimestamp: String,
): MessageSearchResultItem.Media? { ): MessageSearchListItem.Media? {
val messageType = when (val content = result.content) { val messageType = when (val content = result.content) {
is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment
else -> return null else -> return null
@@ -342,7 +355,7 @@ class GlobalSearchPresenter(
blurhash = blurhash blurhash = blurhash
) )
return MessageSearchResultItem.Media( return MessageSearchListItem.Media(
messageSearchResult = result, messageSearchResult = result,
mediaContent = mediaContent, mediaContent = mediaContent,
formattedTimestamp = formattedTimestamp, formattedTimestamp = formattedTimestamp,
@@ -28,20 +28,26 @@ data class GlobalSearchState(
val queryState: TextFieldState, val queryState: TextFieldState,
val currentTarget: GlobalSearchTarget, val currentTarget: GlobalSearchTarget,
val results: AsyncData<GlobalSearchResults>, val results: AsyncData<GlobalSearchResults>,
val history: AsyncData<ImmutableList<SearchHistoryResultItem>>, val history: AsyncData<ImmutableList<SearchHistoryListItem>>,
val eventSink: (GlobalSearchEvent) -> Unit val eventSink: (GlobalSearchEvent) -> Unit
) )
/**
* The target of the global search: either rooms or messages.
*/
@Immutable @Immutable
enum class GlobalSearchTarget { enum class GlobalSearchTarget {
ROOMS, ROOMS,
MESSAGES, MESSAGES,
} }
/**
* The results of a global search, either a list of rooms or a list of messages.
*/
@Immutable @Immutable
sealed interface GlobalSearchResults { sealed interface GlobalSearchResults {
data class RoomListResults(val results: ImmutableList<RoomListRoomSummary>) : GlobalSearchResults data class RoomListResults(val results: ImmutableList<RoomListRoomSummary>) : GlobalSearchResults
data class MessageSearchResults(val results: ImmutableList<MessageSearchResultItem>) : GlobalSearchResults data class MessageSearchResults(val results: ImmutableList<MessageSearchListItem>) : GlobalSearchResults
fun isEmpty(): Boolean = when (this) { fun isEmpty(): Boolean = when (this) {
is RoomListResults -> results.isEmpty() is RoomListResults -> results.isEmpty()
@@ -49,7 +55,10 @@ sealed interface GlobalSearchResults {
} }
} }
sealed interface MessageSearchResultItem { /**
* A single item in the message search results. It's the UI representation of [MessageSearchResult], which is the mapped model returned by the Matrix SDK.
*/
sealed interface MessageSearchListItem {
val roomId: RoomId val roomId: RoomId
val eventId: EventId val eventId: EventId
val senderId: UserId val senderId: UserId
@@ -57,24 +66,30 @@ sealed interface MessageSearchResultItem {
val roomInfo: RoomInfo val roomInfo: RoomInfo
val formattedTimestamp: String val formattedTimestamp: String
/**
* A message item in the message search results.
*/
data class Message( data class Message(
val messageSearchResult: MessageSearchResult, val messageSearchResult: MessageSearchResult,
val body: String, val body: String,
override val roomInfo: RoomInfo, override val roomInfo: RoomInfo,
override val formattedTimestamp: String, override val formattedTimestamp: String,
) : MessageSearchResultItem { ) : MessageSearchListItem {
override val roomId: RoomId = messageSearchResult.roomId override val roomId: RoomId = messageSearchResult.roomId
override val eventId: EventId = messageSearchResult.eventId override val eventId: EventId = messageSearchResult.eventId
override val senderId: UserId = messageSearchResult.senderId override val senderId: UserId = messageSearchResult.senderId
override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId) override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId)
} }
/**
* A message with media item in the message search results.
*/
data class Media( data class Media(
val messageSearchResult: MessageSearchResult, val messageSearchResult: MessageSearchResult,
val mediaContent: MediaSearchResultContent, val mediaContent: MediaSearchResultContent,
override val roomInfo: RoomInfo, override val roomInfo: RoomInfo,
override val formattedTimestamp: String, override val formattedTimestamp: String,
) : MessageSearchResultItem { ) : MessageSearchListItem {
override val roomId: RoomId = messageSearchResult.roomId override val roomId: RoomId = messageSearchResult.roomId
override val eventId: EventId = messageSearchResult.eventId override val eventId: EventId = messageSearchResult.eventId
override val senderId: UserId = messageSearchResult.senderId override val senderId: UserId = messageSearchResult.senderId
@@ -82,20 +97,28 @@ sealed interface MessageSearchResultItem {
} }
} }
sealed interface SearchHistoryResultItem { /**
* A single entry in the global search history, displayed in the list of recent searches.
*
* It's the UI representation of [io.element.android.features.home.impl.search.history.SearchHistoryResult], which is the persisted model.
*/
sealed interface SearchHistoryListItem {
val id: String val id: String
data class Query(val term: String) : SearchHistoryResultItem { data class Query(val term: String) : SearchHistoryListItem {
override val id: String = "query:$term" override val id: String = "query:$term"
} }
data class Room( data class Room(
val roomId: RoomId, val roomId: RoomId,
val roomInfo: RoomInfo, val roomInfo: RoomInfo,
) : SearchHistoryResultItem { ) : SearchHistoryListItem {
override val id: String = "room:${roomId.value}" override val id: String = "room:${roomId.value}"
} }
} }
/**
* Represents the media content of a message search result.
*/
data class MediaSearchResultContent( data class MediaSearchResultContent(
val filename: String, val filename: String,
val extension: String?, val extension: String?,
@@ -79,13 +79,13 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
currentTarget = GlobalSearchTarget.MESSAGES, currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"), queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf( results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
MessageSearchResultItem.Message( MessageSearchListItem.Message(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")), messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
body = "A message with Query", body = "A message with Query",
roomInfo = aRoomInfo(), roomInfo = aRoomInfo(),
formattedTimestamp = "12:00", formattedTimestamp = "12:00",
), ),
MessageSearchResultItem.Media( MessageSearchListItem.Media(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")), messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
mediaContent = MediaSearchResultContent( mediaContent = MediaSearchResultContent(
filename = "file.png", filename = "file.png",
@@ -114,9 +114,9 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
results = AsyncData.Uninitialized, results = AsyncData.Uninitialized,
history = AsyncData.Success( history = AsyncData.Success(
persistentListOf( persistentListOf(
SearchHistoryResultItem.Query("Query 1"), SearchHistoryListItem.Query("Query 1"),
SearchHistoryResultItem.Query("Query 2"), SearchHistoryListItem.Query("Query 2"),
SearchHistoryResultItem.Room( SearchHistoryListItem.Room(
roomId = roomId1, roomId = roomId1,
roomInfo = aRoomInfo( roomInfo = aRoomInfo(
id = roomId1, id = roomId1,
@@ -125,7 +125,7 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
canonicalAlias = aCanonicalAlias, canonicalAlias = aCanonicalAlias,
) )
), ),
SearchHistoryResultItem.Room( SearchHistoryListItem.Room(
roomId = roomId2, roomId = roomId2,
roomInfo = aRoomInfo( roomInfo = aRoomInfo(
id = roomId2, id = roomId2,
@@ -133,7 +133,7 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
) )
), ),
SearchHistoryResultItem.Room( SearchHistoryListItem.Room(
roomId = dmRoomId, roomId = dmRoomId,
roomInfo = aRoomInfo( roomInfo = aRoomInfo(
id = dmRoomId, id = dmRoomId,
@@ -144,7 +144,7 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
canonicalAlias = aCanonicalAlias, canonicalAlias = aCanonicalAlias,
) )
), ),
SearchHistoryResultItem.Room( SearchHistoryListItem.Room(
roomId = tombstonedRoomId, roomId = tombstonedRoomId,
roomInfo = aRoomInfo( roomInfo = aRoomInfo(
id = tombstonedRoomId, id = tombstonedRoomId,
@@ -300,7 +300,7 @@ internal fun aGlobalSearchState(
queryState: TextFieldState = TextFieldState(), queryState: TextFieldState = TextFieldState(),
currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS, currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized, results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized,
history: AsyncData<ImmutableList<SearchHistoryResultItem>> = AsyncData.Uninitialized, history: AsyncData<ImmutableList<SearchHistoryListItem>> = AsyncData.Uninitialized,
eventSink: (GlobalSearchEvent) -> Unit = {}, eventSink: (GlobalSearchEvent) -> Unit = {},
) = GlobalSearchState( ) = GlobalSearchState(
isEnabled = isEnabled, isEnabled = isEnabled,
@@ -12,12 +12,14 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -31,9 +33,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.KeyboardActionHandler import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -67,6 +71,8 @@ import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.roundToPx import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.FilledTextField 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.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@@ -75,6 +81,7 @@ import io.element.android.libraries.designsystem.theme.components.LinearProgress
import io.element.android.libraries.designsystem.theme.components.Scaffold 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.SegmentedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
@@ -155,10 +162,14 @@ private fun GlobalSearchContent(
val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf() val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf()
when { when {
state.results.isUninitialized() -> if (state.queryState.text.isBlank() && historyResults.isNotEmpty()) { state.results.isUninitialized() -> if (state.queryState.text.isBlank() && historyResults.isNotEmpty()) {
searchHistory(historyResults) { result -> searchHistory(
historyResults = historyResults,
onRemoveResult = { item -> state.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(item)) },
onClearAllResults = { state.eventSink(GlobalSearchEvent.ClearSearchHistory) },
) { result ->
when (result) { when (result) {
is SearchHistoryResultItem.Query -> Unit is SearchHistoryListItem.Query -> Unit
is SearchHistoryResultItem.Room -> onSelectSearchResult(result.roomInfo.id, null) is SearchHistoryListItem.Room -> onSelectSearchResult(result.roomInfo.id, null)
} }
state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result)) state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result))
@@ -214,7 +225,7 @@ private fun SearchTopAppBarContent(state: GlobalSearchState) {
modifier = Modifier.clip(CircleShape).size(20.dp), modifier = Modifier.clip(CircleShape).size(20.dp),
onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }, onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) },
colors = IconButtonDefaults.iconButtonColors( colors = IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.borderInteractivePrimary, containerColor = ElementTheme.colors.iconSecondary,
contentColor = ElementTheme.colors.iconOnSolidPrimary contentColor = ElementTheme.colors.iconOnSolidPrimary
) )
) { ) {
@@ -279,31 +290,52 @@ private fun LazyListScope.startSearching() {
} }
private fun LazyListScope.searchHistory( private fun LazyListScope.searchHistory(
historyResults: ImmutableList<SearchHistoryResultItem>, historyResults: ImmutableList<SearchHistoryListItem>,
onSelectSearchHistoryResult: (SearchHistoryResultItem) -> Unit, onRemoveResult: (SearchHistoryListItem) -> Unit,
onClearAllResults: () -> Unit,
onSelectSearchHistoryResult: (SearchHistoryListItem) -> Unit,
) { ) {
item { item {
Text( Row(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), modifier = Modifier.padding(top = 16.dp),
text = stringResource(R.string.screen_search_recent_searches_section_title), verticalAlignment = Alignment.CenterVertically,
style = ElementTheme.typography.fontBodyLgMedium, ) {
color = ElementTheme.colors.textPrimary, Text(
) modifier = Modifier.padding(start = 16.dp).weight(1f),
text = stringResource(R.string.screen_search_recent_searches_section_title),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
TextButton(
text = stringResource(CommonStrings.action_clear_all),
size = ButtonSize.Small,
colors = ButtonDefaults.textButtonColors(contentColor = ElementTheme.colors.textActionAccent),
onClick = onClearAllResults,
)
}
} }
items( items(
items = historyResults, items = historyResults,
key = { it.id }, key = { it.id },
) { result -> ) { result ->
val twoLineHeight =
// Vertical padding
12.dp * 2 +
// Text line heights
ElementTheme.typography.fontBodyMdRegular.lineHeight.toDp() +
ElementTheme.typography.fontBodyLgRegular.lineHeight.toDp()
Row( Row(
modifier = Modifier modifier = Modifier
.niceClickable(onClick = { onSelectSearchHistoryResult(result) }) .clickable(onClick = { onSelectSearchHistoryResult(result) })
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(start = 16.dp, end = 2.dp)
.height(twoLineHeight),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
when (result) { when (result) {
is SearchHistoryResultItem.Query -> { is SearchHistoryListItem.Query -> {
Icon( Icon(
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
@@ -315,13 +347,14 @@ private fun LazyListScope.searchHistory(
) )
Text( Text(
modifier = Modifier.weight(1f, fill = true),
text = result.term, text = result.term,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgRegular,
) )
} }
is SearchHistoryResultItem.Room -> { is SearchHistoryListItem.Room -> {
val heroAvatars = remember(result.roomInfo.heroes) { val heroAvatars = remember(result.roomInfo.heroes) {
result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.SearchRoomListItem) }.toImmutableList() result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.SearchRoomListItem) }.toImmutableList()
} }
@@ -333,7 +366,7 @@ private fun LazyListScope.searchHistory(
), ),
) )
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.weight(1f, fill = true)) {
Text( Text(
text = result.roomInfo.name.orEmpty(), text = result.roomInfo.name.orEmpty(),
maxLines = 1, maxLines = 1,
@@ -365,6 +398,16 @@ private fun LazyListScope.searchHistory(
} }
} }
} }
Icon(
modifier = Modifier.minimumInteractiveComponentSize()
.niceClickable {
onRemoveResult(result)
},
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_remove),
tint = ElementTheme.colors.iconSecondary,
)
} }
} }
} }
@@ -451,7 +494,7 @@ private fun LazyListScope.roomListResults(
} }
private fun LazyListScope.messageSearchResults( private fun LazyListScope.messageSearchResults(
results: ImmutableList<MessageSearchResultItem>, results: ImmutableList<MessageSearchListItem>,
onSearchResultSelected: (RoomId, EventId?) -> Unit, onSearchResultSelected: (RoomId, EventId?) -> Unit,
) { ) {
if (results.isNotEmpty()) { if (results.isNotEmpty()) {
@@ -471,7 +514,7 @@ private fun LazyListScope.messageSearchResults(
) { index, result -> ) { index, result ->
val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) }) val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) })
when (result) { when (result) {
is MessageSearchResultItem.Message -> TextMessageSearchResultItemView( is MessageSearchListItem.Message -> TextMessageSearchResultItemView(
modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
avatarType = AvatarType.Room( avatarType = AvatarType.Room(
@@ -482,7 +525,7 @@ private fun LazyListScope.messageSearchResults(
body = result.body, body = result.body,
formattedTimestamp = result.formattedTimestamp, formattedTimestamp = result.formattedTimestamp,
) )
is MessageSearchResultItem.Media -> { is MessageSearchListItem.Media -> {
MediaMessageSearchResultItemView( MediaMessageSearchResultItemView(
modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
@@ -54,6 +54,17 @@ class DefaultSearchHistoryStore(
} }
} }
override suspend fun remove(result: SearchHistoryResult) {
withContext(dispatchers.io) {
mutex.withLock {
ensureLoadedLocked()
val updated = cache.value.filterNot { it == result }
cache.value = updated
persist(updated)
}
}
}
override suspend fun clear() = withContext<Unit>(dispatchers.io) { override suspend fun clear() = withContext<Unit>(dispatchers.io) {
mutex.withLock { mutex.withLock {
isLoaded = true isLoaded = true
@@ -18,9 +18,15 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
sealed interface SearchHistoryResult { sealed interface SearchHistoryResult {
@Serializable val id: String
data class Query(val term: String) : SearchHistoryResult
@Serializable @Serializable
data class Room(val roomId: RoomId) : SearchHistoryResult data class Query(val term: String) : SearchHistoryResult {
override val id: String = term
}
@Serializable
data class Room(val roomId: RoomId) : SearchHistoryResult {
override val id: String = roomId.value
}
} }
@@ -30,6 +30,11 @@ interface SearchHistoryStore {
*/ */
suspend fun add(result: SearchHistoryResult) suspend fun add(result: SearchHistoryResult)
/**
* Remove a [result] from the history.
*/
suspend fun remove(result: SearchHistoryResult)
/** /**
* Remove every entry from the history. * Remove every entry from the history.
*/ */
@@ -224,7 +224,7 @@ class GlobalSearchPresenterTest {
val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results
assertThat(results).hasSize(1) assertThat(results).hasSize(1)
assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) assertThat(results.first()).isInstanceOf(MessageSearchListItem.Message::class.java)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@@ -251,7 +251,7 @@ class GlobalSearchPresenterTest {
val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results
assertThat(results).hasSize(1) assertThat(results).hasSize(1)
assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) assertThat(results.first()).isInstanceOf(MessageSearchListItem.Message::class.java)
// Remove the query // Remove the query
successState.queryState.edit { replace(0, length, "") } successState.queryState.edit { replace(0, length, "") }
@@ -337,6 +337,71 @@ class GlobalSearchPresenterTest {
} }
} }
@Test
fun `present - RemoveSearchHistoryResult removes a query entry`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore().apply {
add(SearchHistoryResult.Query("keep"))
add(SearchHistoryResult.Query("drop"))
}
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Query("drop")))
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - RemoveSearchHistoryResult removes a room entry`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore().apply {
add(SearchHistoryResult.Query("keep"))
add(SearchHistoryResult.Room(A_ROOM_ID))
}
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Room(A_ROOM_ID, aRoomInfo())))
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - RemoveSearchHistoryResult with an unknown id does nothing`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore().apply { add(SearchHistoryResult.Query("keep")) }
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Query("query:unknown")))
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - ClearSearchHistory removes every entry`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore().apply {
add(SearchHistoryResult.Query("a"))
add(SearchHistoryResult.Room(A_ROOM_ID))
}
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.ClearSearchHistory)
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - SearchHistoryResultSelected does nothing for a room result`() = runTest { fun `present - SearchHistoryResultSelected does nothing for a room result`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore() val searchHistoryStore = FakeSearchHistoryStore()
@@ -345,7 +410,7 @@ class GlobalSearchPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1) skipItems(1)
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Room(A_ROOM_ID, aRoomInfo()))) initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryListItem.Room(A_ROOM_ID, aRoomInfo())))
testScheduler.advanceUntilIdle() testScheduler.advanceUntilIdle()
ensureAllEventsConsumed() ensureAllEventsConsumed()
@@ -361,7 +426,7 @@ class GlobalSearchPresenterTest {
// Skip loading search results state // Skip loading search results state
skipItems(1) skipItems(1)
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Query("Test"))) initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryListItem.Query("Test")))
assertThat(awaitItem().queryState.text.toString()).isEqualTo("Test") assertThat(awaitItem().queryState.text.toString()).isEqualTo("Test")
assertThat(awaitItem().results.isLoading()).isTrue() assertThat(awaitItem().results.isLoading()).isTrue()
@@ -162,7 +162,7 @@ class GlobalSearchViewTest : RobolectricTest() {
results = AsyncData.Success( results = AsyncData.Success(
GlobalSearchResults.MessageSearchResults( GlobalSearchResults.MessageSearchResults(
persistentListOf( persistentListOf(
MessageSearchResultItem.Message( MessageSearchListItem.Message(
messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId), messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId),
body = "A message body", body = "A message body",
roomInfo = aRoomInfo(id = roomId, name = "A message room"), roomInfo = aRoomInfo(id = roomId, name = "A message room"),
@@ -184,7 +184,7 @@ class GlobalSearchViewTest : RobolectricTest() {
@Test @Test
fun `clicking on a history query result - emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on a history query result - emits the expected Event`() = runAndroidComposeUiTest {
val queryResult = SearchHistoryResultItem.Query("Recent term") val queryResult = SearchHistoryListItem.Query("Recent term")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>() val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView( setGlobalSearchView(
state = aGlobalSearchState( state = aGlobalSearchState(
@@ -203,10 +203,47 @@ class GlobalSearchViewTest : RobolectricTest() {
eventsRecorder.assertSingle(GlobalSearchEvent.SearchHistoryResultSelected(queryResult)) eventsRecorder.assertSingle(GlobalSearchEvent.SearchHistoryResultSelected(queryResult))
} }
@Test
fun `clicking on the remove button of a history result - emits the expected Event`() = runAndroidComposeUiTest {
val queryResult = SearchHistoryListItem.Query("Recent term")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(persistentListOf(queryResult)),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithContentDescription(activity!!.getString(CommonStrings.action_remove)).performClick()
eventsRecorder.assertSingle(GlobalSearchEvent.RemoveSearchHistoryResult(queryResult))
}
@Test
fun `clicking on clear all in the history - emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(persistentListOf(SearchHistoryListItem.Query("Recent term"))),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("Clear all").performClick()
eventsRecorder.assertSingle(GlobalSearchEvent.ClearSearchHistory)
}
@Test @Test
fun `clicking on a history room result - invokes the callback and emits the expected Events`() = runAndroidComposeUiTest { fun `clicking on a history room result - invokes the callback and emits the expected Events`() = runAndroidComposeUiTest {
val roomId = RoomId("!aHistoryRoom:server.org") val roomId = RoomId("!aHistoryRoom:server.org")
val roomResult = SearchHistoryResultItem.Room( val roomResult = SearchHistoryListItem.Room(
roomId = roomId, roomId = roomId,
roomInfo = aRoomInfo(id = roomId, name = "A history room"), roomInfo = aRoomInfo(id = roomId, name = "A history room"),
) )
@@ -89,6 +89,31 @@ class DefaultSearchHistoryStoreTest {
).inOrder() ).inOrder()
} }
@Test
fun `remove deletes only the given entry and persists the change`() = runTest {
val fileStore = FakeSearchHistoryFileStore()
val store = createStore(fileStore)
store.add(SearchHistoryResult.Query("a"))
store.add(SearchHistoryResult.Room(A_ROOM_ID))
store.add(SearchHistoryResult.Query("b"))
store.remove(SearchHistoryResult.Room(A_ROOM_ID))
val expected = listOf(SearchHistoryResult.Query("b"), SearchHistoryResult.Query("a"))
assertThat(store.history.first()).containsExactlyElementsIn(expected).inOrder()
assertThat(createStore(fileStore).history.first()).containsExactlyElementsIn(expected).inOrder()
}
@Test
fun `remove of an unknown entry leaves the history unchanged`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("a"))
store.remove(SearchHistoryResult.Query("unknown"))
assertThat(store.history.first()).containsExactly(SearchHistoryResult.Query("a"))
}
@Test @Test
fun `clear removes every entry and deletes the persisted content`() = runTest { fun `clear removes every entry and deletes the persisted content`() = runTest {
val fileStore = FakeSearchHistoryFileStore() val fileStore = FakeSearchHistoryFileStore()
@@ -22,6 +22,10 @@ class FakeSearchHistoryStore : SearchHistoryStore {
}.take(MAX_SEARCH_HISTORY_SIZE) }.take(MAX_SEARCH_HISTORY_SIZE)
} }
override suspend fun remove(result: SearchHistoryResult) {
state.value = state.value.filterNot { it == result }
}
override suspend fun clear() { override suspend fun clear() {
state.value = emptyList() state.value = emptyList()
} }
@@ -109,6 +109,7 @@ fun TextButton(
showProgress: Boolean = false, showProgress: Boolean = false,
destructive: Boolean = false, destructive: Boolean = false,
leadingIcon: IconSource? = null, leadingIcon: IconSource? = null,
colors: ButtonColors = ButtonStyle.Text.getColors(destructive),
) = ButtonInternal( ) = ButtonInternal(
text = text, text = text,
onClick = onClick, onClick = onClick,
@@ -118,7 +119,8 @@ fun TextButton(
size = size, size = size,
showProgress = showProgress, showProgress = showProgress,
destructive = destructive, destructive = destructive,
leadingIcon = leadingIcon leadingIcon = leadingIcon,
colors = colors,
) )
@Composable @Composable
@@ -98,6 +98,7 @@
<string name="action_choose_file">"Choose file"</string> <string name="action_choose_file">"Choose file"</string>
<string name="action_choose_photo">"Choose photo"</string> <string name="action_choose_photo">"Choose photo"</string>
<string name="action_clear">"Clear"</string> <string name="action_clear">"Clear"</string>
<string name="action_clear_all">"Clear all"</string>
<string name="action_close">"Close"</string> <string name="action_close">"Close"</string>
<string name="action_complete_verification">"Complete verification"</string> <string name="action_complete_verification">"Complete verification"</string>
<string name="action_confirm">"Confirm"</string> <string name="action_confirm">"Confirm"</string>