mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +00:00
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:
committed by
GitHub
parent
d589ff37dc
commit
6cde0706be
+7
-1
@@ -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
|
||||
}
|
||||
|
||||
+22
-9
@@ -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,
|
||||
|
||||
+31
-8
@@ -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?,
|
||||
|
||||
+9
-9
@@ -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,
|
||||
|
||||
+63
-20
@@ -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),
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
+9
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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.
|
||||
*/
|
||||
|
||||
+69
-4
@@ -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()
|
||||
|
||||
+40
-3
@@ -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"),
|
||||
)
|
||||
|
||||
+25
@@ -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()
|
||||
|
||||
+4
@@ -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()
|
||||
}
|
||||
|
||||
+3
-1
@@ -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>
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
Reference in New Issue
Block a user