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
/** 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.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
@@ -87,14 +88,14 @@ 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) {
val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryListItem>>>(initialValue = AsyncData.Uninitialized) {
searchHistoryStore.history.collectLatest { history ->
val mappedHistoryItems = history.mapNotNull { result ->
when (result) {
is SearchHistoryResult.Query -> SearchHistoryResultItem.Query(term = result.term)
is SearchHistoryResult.Query -> SearchHistoryListItem.Query(term = result.term)
is SearchHistoryResult.Room -> {
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 -> {
when (event.resultItem) {
is SearchHistoryResultItem.Query -> coroutineScope.launch {
is SearchHistoryListItem.Query -> coroutineScope.launch {
delay(300.milliseconds)
queryState.edit {
replace(0, length, event.resultItem.term)
@@ -244,9 +245,21 @@ class GlobalSearchPresenter(
searchResults = AsyncData.Loading()
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,
roomInfo: RoomInfo,
formattedTimestamp: String,
): MessageSearchResultItem.Message? {
): MessageSearchListItem.Message? {
val body = latestEventFormatter.format(
latestEvent = LatestEventValue.Remote(
timestamp = result.timestamp,
@@ -276,7 +289,7 @@ class GlobalSearchPresenter(
),
isDmRoom = false,
)
return MessageSearchResultItem.Message(
return MessageSearchListItem.Message(
messageSearchResult = result,
body = body?.toString() ?: "",
formattedTimestamp = formattedTimestamp,
@@ -288,7 +301,7 @@ class GlobalSearchPresenter(
result: MessageSearchResult,
roomInfo: RoomInfo,
formattedTimestamp: String,
): MessageSearchResultItem.Media? {
): MessageSearchListItem.Media? {
val messageType = when (val content = result.content) {
is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment
else -> return null
@@ -342,7 +355,7 @@ class GlobalSearchPresenter(
blurhash = blurhash
)
return MessageSearchResultItem.Media(
return MessageSearchListItem.Media(
messageSearchResult = result,
mediaContent = mediaContent,
formattedTimestamp = formattedTimestamp,
@@ -28,20 +28,26 @@ data class GlobalSearchState(
val queryState: TextFieldState,
val currentTarget: GlobalSearchTarget,
val results: AsyncData<GlobalSearchResults>,
val history: AsyncData<ImmutableList<SearchHistoryResultItem>>,
val history: AsyncData<ImmutableList<SearchHistoryListItem>>,
val eventSink: (GlobalSearchEvent) -> Unit
)
/**
* The target of the global search: either rooms or messages.
*/
@Immutable
enum class GlobalSearchTarget {
ROOMS,
MESSAGES,
}
/**
* The results of a global search, either a list of rooms or a list of messages.
*/
@Immutable
sealed interface 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) {
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 eventId: EventId
val senderId: UserId
@@ -57,24 +66,30 @@ sealed interface MessageSearchResultItem {
val roomInfo: RoomInfo
val formattedTimestamp: String
/**
* A message item in the message search results.
*/
data class Message(
val messageSearchResult: MessageSearchResult,
val body: String,
override val roomInfo: RoomInfo,
override val formattedTimestamp: String,
) : MessageSearchResultItem {
) : MessageSearchListItem {
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)
}
/**
* A message with media item in the message search results.
*/
data class Media(
val messageSearchResult: MessageSearchResult,
val mediaContent: MediaSearchResultContent,
override val roomInfo: RoomInfo,
override val formattedTimestamp: String,
) : MessageSearchResultItem {
) : MessageSearchListItem {
override val roomId: RoomId = messageSearchResult.roomId
override val eventId: EventId = messageSearchResult.eventId
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
data class Query(val term: String) : SearchHistoryResultItem {
data class Query(val term: String) : SearchHistoryListItem {
override val id: String = "query:$term"
}
data class Room(
val roomId: RoomId,
val roomInfo: RoomInfo,
) : SearchHistoryResultItem {
) : SearchHistoryListItem {
override val id: String = "room:${roomId.value}"
}
}
/**
* Represents the media content of a message search result.
*/
data class MediaSearchResultContent(
val filename: String,
val extension: String?,
@@ -79,13 +79,13 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
MessageSearchResultItem.Message(
MessageSearchListItem.Message(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
body = "A message with Query",
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
MessageSearchResultItem.Media(
MessageSearchListItem.Media(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
mediaContent = MediaSearchResultContent(
filename = "file.png",
@@ -114,9 +114,9 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
results = AsyncData.Uninitialized,
history = AsyncData.Success(
persistentListOf(
SearchHistoryResultItem.Query("Query 1"),
SearchHistoryResultItem.Query("Query 2"),
SearchHistoryResultItem.Room(
SearchHistoryListItem.Query("Query 1"),
SearchHistoryListItem.Query("Query 2"),
SearchHistoryListItem.Room(
roomId = roomId1,
roomInfo = aRoomInfo(
id = roomId1,
@@ -125,7 +125,7 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
canonicalAlias = aCanonicalAlias,
)
),
SearchHistoryResultItem.Room(
SearchHistoryListItem.Room(
roomId = roomId2,
roomInfo = aRoomInfo(
id = roomId2,
@@ -133,7 +133,7 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
avatarUrl = AN_AVATAR_URL,
)
),
SearchHistoryResultItem.Room(
SearchHistoryListItem.Room(
roomId = dmRoomId,
roomInfo = aRoomInfo(
id = dmRoomId,
@@ -144,7 +144,7 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
canonicalAlias = aCanonicalAlias,
)
),
SearchHistoryResultItem.Room(
SearchHistoryListItem.Room(
roomId = tombstonedRoomId,
roomInfo = aRoomInfo(
id = tombstonedRoomId,
@@ -300,7 +300,7 @@ internal fun aGlobalSearchState(
queryState: TextFieldState = TextFieldState(),
currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized,
history: AsyncData<ImmutableList<SearchHistoryResultItem>> = AsyncData.Uninitialized,
history: AsyncData<ImmutableList<SearchHistoryListItem>> = AsyncData.Uninitialized,
eventSink: (GlobalSearchEvent) -> Unit = {},
) = GlobalSearchState(
isEnabled = isEnabled,
@@ -12,12 +12,14 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.PreviewsDayNight
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.HorizontalDivider
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.SegmentedButton
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.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
@@ -155,10 +162,14 @@ private fun GlobalSearchContent(
val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf()
when {
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) {
is SearchHistoryResultItem.Query -> Unit
is SearchHistoryResultItem.Room -> onSelectSearchResult(result.roomInfo.id, null)
is SearchHistoryListItem.Query -> Unit
is SearchHistoryListItem.Room -> onSelectSearchResult(result.roomInfo.id, null)
}
state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result))
@@ -214,7 +225,7 @@ private fun SearchTopAppBarContent(state: GlobalSearchState) {
modifier = Modifier.clip(CircleShape).size(20.dp),
onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) },
colors = IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.borderInteractivePrimary,
containerColor = ElementTheme.colors.iconSecondary,
contentColor = ElementTheme.colors.iconOnSolidPrimary
)
) {
@@ -279,31 +290,52 @@ private fun LazyListScope.startSearching() {
}
private fun LazyListScope.searchHistory(
historyResults: ImmutableList<SearchHistoryResultItem>,
onSelectSearchHistoryResult: (SearchHistoryResultItem) -> Unit,
historyResults: ImmutableList<SearchHistoryListItem>,
onRemoveResult: (SearchHistoryListItem) -> Unit,
onClearAllResults: () -> Unit,
onSelectSearchHistoryResult: (SearchHistoryListItem) -> 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,
)
Row(
modifier = Modifier.padding(top = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
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 = historyResults,
key = { it.id },
) { result ->
val twoLineHeight =
// Vertical padding
12.dp * 2 +
// Text line heights
ElementTheme.typography.fontBodyMdRegular.lineHeight.toDp() +
ElementTheme.typography.fontBodyLgRegular.lineHeight.toDp()
Row(
modifier = Modifier
.niceClickable(onClick = { onSelectSearchHistoryResult(result) })
.clickable(onClick = { onSelectSearchHistoryResult(result) })
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(start = 16.dp, end = 2.dp)
.height(twoLineHeight),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (result) {
is SearchHistoryResultItem.Query -> {
is SearchHistoryListItem.Query -> {
Icon(
modifier = Modifier.size(32.dp)
.clip(RoundedCornerShape(8.dp))
@@ -315,13 +347,14 @@ private fun LazyListScope.searchHistory(
)
Text(
modifier = Modifier.weight(1f, fill = true),
text = result.term,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
)
}
is SearchHistoryResultItem.Room -> {
is SearchHistoryListItem.Room -> {
val heroAvatars = remember(result.roomInfo.heroes) {
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 = result.roomInfo.name.orEmpty(),
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(
results: ImmutableList<MessageSearchResultItem>,
results: ImmutableList<MessageSearchListItem>,
onSearchResultSelected: (RoomId, EventId?) -> Unit,
) {
if (results.isNotEmpty()) {
@@ -471,7 +514,7 @@ private fun LazyListScope.messageSearchResults(
) { index, result ->
val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) })
when (result) {
is MessageSearchResultItem.Message -> TextMessageSearchResultItemView(
is MessageSearchListItem.Message -> TextMessageSearchResultItemView(
modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
avatarType = AvatarType.Room(
@@ -482,7 +525,7 @@ private fun LazyListScope.messageSearchResults(
body = result.body,
formattedTimestamp = result.formattedTimestamp,
)
is MessageSearchResultItem.Media -> {
is MessageSearchListItem.Media -> {
MediaMessageSearchResultItemView(
modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
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) {
mutex.withLock {
isLoaded = true
@@ -18,9 +18,15 @@ import kotlinx.serialization.Serializable
*/
@Serializable
sealed interface SearchHistoryResult {
@Serializable
data class Query(val term: String) : SearchHistoryResult
val id: String
@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)
/**
* Remove a [result] from the history.
*/
suspend fun remove(result: SearchHistoryResult)
/**
* Remove every entry from the history.
*/
@@ -224,7 +224,7 @@ class GlobalSearchPresenterTest {
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)
assertThat(results.first()).isInstanceOf(MessageSearchListItem.Message::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@@ -251,7 +251,7 @@ class GlobalSearchPresenterTest {
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)
assertThat(results.first()).isInstanceOf(MessageSearchListItem.Message::class.java)
// Remove the query
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
fun `present - SearchHistoryResultSelected does nothing for a room result`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
@@ -345,7 +410,7 @@ class GlobalSearchPresenterTest {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Room(A_ROOM_ID, aRoomInfo())))
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryListItem.Room(A_ROOM_ID, aRoomInfo())))
testScheduler.advanceUntilIdle()
ensureAllEventsConsumed()
@@ -361,7 +426,7 @@ class GlobalSearchPresenterTest {
// Skip loading search results state
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().results.isLoading()).isTrue()
@@ -162,7 +162,7 @@ class GlobalSearchViewTest : RobolectricTest() {
results = AsyncData.Success(
GlobalSearchResults.MessageSearchResults(
persistentListOf(
MessageSearchResultItem.Message(
MessageSearchListItem.Message(
messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId),
body = "A message body",
roomInfo = aRoomInfo(id = roomId, name = "A message room"),
@@ -184,7 +184,7 @@ class GlobalSearchViewTest : RobolectricTest() {
@Test
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>()
setGlobalSearchView(
state = aGlobalSearchState(
@@ -203,10 +203,47 @@ class GlobalSearchViewTest : RobolectricTest() {
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
fun `clicking on a history room result - invokes the callback and emits the expected Events`() = runAndroidComposeUiTest {
val roomId = RoomId("!aHistoryRoom:server.org")
val roomResult = SearchHistoryResultItem.Room(
val roomResult = SearchHistoryListItem.Room(
roomId = roomId,
roomInfo = aRoomInfo(id = roomId, name = "A history room"),
)
@@ -89,6 +89,31 @@ class DefaultSearchHistoryStoreTest {
).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
fun `clear removes every entry and deletes the persisted content`() = runTest {
val fileStore = FakeSearchHistoryFileStore()
@@ -22,6 +22,10 @@ class FakeSearchHistoryStore : SearchHistoryStore {
}.take(MAX_SEARCH_HISTORY_SIZE)
}
override suspend fun remove(result: SearchHistoryResult) {
state.value = state.value.filterNot { it == result }
}
override suspend fun clear() {
state.value = emptyList()
}
@@ -109,6 +109,7 @@ fun TextButton(
showProgress: Boolean = false,
destructive: Boolean = false,
leadingIcon: IconSource? = null,
colors: ButtonColors = ButtonStyle.Text.getColors(destructive),
) = ButtonInternal(
text = text,
onClick = onClick,
@@ -118,7 +119,8 @@ fun TextButton(
size = size,
showProgress = showProgress,
destructive = destructive,
leadingIcon = leadingIcon
leadingIcon = leadingIcon,
colors = colors,
)
@Composable
@@ -98,6 +98,7 @@
<string name="action_choose_file">"Choose file"</string>
<string name="action_choose_photo">"Choose photo"</string>
<string name="action_clear">"Clear"</string>
<string name="action_clear_all">"Clear all"</string>
<string name="action_close">"Close"</string>
<string name="action_complete_verification">"Complete verification"</string>
<string name="action_confirm">"Confirm"</string>