diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreSearchHistoryStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreSearchHistoryStorage.kt new file mode 100644 index 0000000000..6820899ad4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreSearchHistoryStorage.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.search.SearchHistoryStorage +import kotlinx.coroutines.flow.first +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Where Android keeps the search history: one `datastore/search_history.preferences_pb` file. + * + * The history itself — what it holds, how much of it, in what order — is + * [com.vitorpamplona.amethyst.commons.search.SearchHistory] in commons, shared with Desktop. This + * is only the two strings and the file they live in. + * + * Device-global rather than per-account, like the drawer's collapse state beside it: what you + * searched for is a property of this phone, and it is never published to a relay. + */ +class DataStoreSearchHistoryStorage( + private val filesDir: File, +) : SearchHistoryStorage { + constructor(context: Context) : this(context.applicationContext.filesDir) + + // DataStore v1 throws if two instances are ever active on the same file, and the search screen + // is rebuilt per seeded query, so the store is shared per absolute path across the process. + private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/search_history.preferences_pb")) + + override suspend fun read(key: String): String? = store.data.first()[stringPreferencesKey(key)] + + override suspend fun write( + key: String, + value: String?, + ) { + store.edit { + if (value.isNullOrBlank()) it.remove(stringPreferencesKey(key)) else it[stringPreferencesKey(key)] = value + } + } + + companion object { + private val stores = ConcurrentHashMap>() + + private fun dataStoreFor(file: File): DataStore = + stores.computeIfAbsent(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index e1cfdf8876..900403e84c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -34,6 +34,8 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.relayClient.search.SearchQueryState import com.vitorpamplona.amethyst.commons.search.RenderableKinds +import com.vitorpamplona.amethyst.commons.search.SearchHistory +import com.vitorpamplona.amethyst.commons.search.SearchHistoryStorage import com.vitorpamplona.amethyst.commons.search.SearchPipeline import com.vitorpamplona.amethyst.commons.search.SearchResultKind import com.vitorpamplona.amethyst.commons.search.SearchScope @@ -88,6 +90,8 @@ import kotlinx.coroutines.flow.update class SearchBarViewModel( val account: Account, val nip05Client: INip05Client, + /** Where this device keeps what has been searched for; see [history]. */ + historyStorage: SearchHistoryStorage, /** * The query the calling screen seeded the box with — `from:` off a profile, * `kind:article` off the articles feed. It is ordinary field text from here on: the reader @@ -122,6 +126,18 @@ class SearchBarViewModel( */ val pickerOpen = MutableStateFlow(false) + /** + * What has been searched for on this device, and what has been kept. + * + * Desktop has had this since the advanced bar was written; Android never did, because the + * code sat in a desktop object on `java.util.prefs`. Nothing about it was desktop-specific, + * so the move to commons is the whole of the port. + */ + val history = SearchHistory(historyStorage, viewModelScope) + + /** Records the query the reader actually ran — the Enter key, not every pause in typing. */ + fun remember() = history.remember(state.query) + val queryAsksNothing get() = state.asksNothing val searchSettled get() = state.settled val scopePinnedToNotes get() = state.scopePinnedToNotes @@ -503,9 +519,10 @@ class SearchBarViewModel( class Factory( val account: Account, val nip05: INip05Client, + val historyStorage: SearchHistoryStorage, val initialQuery: String? = null, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = SearchBarViewModel(account, nip05, initialQuery) as T + override fun create(modelClass: Class): T = SearchBarViewModel(account, nip05, historyStorage, initialQuery) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 853bb9ead1..afc52ae150 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -70,6 +70,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign @@ -91,6 +92,8 @@ import com.vitorpamplona.amethyst.commons.resources.search_filters_title import com.vitorpamplona.amethyst.commons.resources.search_follows_only import com.vitorpamplona.amethyst.commons.resources.search_no_results import com.vitorpamplona.amethyst.commons.resources.search_no_results_explainer +import com.vitorpamplona.amethyst.commons.resources.search_recent +import com.vitorpamplona.amethyst.commons.resources.search_recent_clear import com.vitorpamplona.amethyst.commons.resources.search_scope_all import com.vitorpamplona.amethyst.commons.resources.search_scope_notes import com.vitorpamplona.amethyst.commons.resources.search_scope_people @@ -102,6 +105,7 @@ import com.vitorpamplona.amethyst.commons.resources.search_source_local import com.vitorpamplona.amethyst.commons.resources.search_source_relays import com.vitorpamplona.amethyst.commons.resources.search_type_to_begin import com.vitorpamplona.amethyst.commons.resources.search_type_to_begin_explainer +import com.vitorpamplona.amethyst.commons.search.QuerySerializer import com.vitorpamplona.amethyst.commons.search.SearchScope import com.vitorpamplona.amethyst.commons.search.SearchSortOrder import com.vitorpamplona.amethyst.commons.search.SearchSource @@ -112,6 +116,7 @@ import com.vitorpamplona.amethyst.commons.ui.search.SearchFieldState import com.vitorpamplona.amethyst.commons.ui.search.TokenizedSearchField import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.model.preferences.DataStoreSearchHistoryStorage import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow @@ -156,6 +161,7 @@ fun SearchScreen( accountViewModel: AccountViewModel, nav: INav, ) { + val historyStorage = LocalContext.current.let { context -> remember(context) { DataStoreSearchHistoryStorage(context) } } val searchBarViewModel: SearchBarViewModel = viewModel( // Keyed on the seed: navigating from one screen's search button to another's has to @@ -165,6 +171,7 @@ fun SearchScreen( SearchBarViewModel.Factory( accountViewModel.account, accountViewModel.nip05ClientBuilder(), + historyStorage, initialQuery, ), ) @@ -638,6 +645,9 @@ private fun SearchTextField( groups = groupCandidates, onPeopleQuery = { userSuggestions.processCurrentWord(it) }, onGroupQuery = { partial -> groupQuery = partial }, + // The Enter key is what says a search was meant, rather than passed through on the way + // to a longer word. Recording every pause in typing would fill the list with prefixes. + onSubmit = { searchBarViewModel.remember() }, peoplePicker = { _, onPick -> ShowUserSuggestionList( userSuggestions = userSuggestions, @@ -698,6 +708,7 @@ private fun DisplaySearchResults( val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle() val asksNothing by searchBarViewModel.queryAsksNothing.collectAsStateWithLifecycle() val settled by searchBarViewModel.searchSettled.collectAsStateWithLifecycle() + val recent by searchBarViewModel.history.recent.collectAsStateWithLifecycle() LazyColumn( modifier = Modifier.fillMaxHeight(), @@ -706,7 +717,43 @@ private fun DisplaySearchResults( ) { item(key = "scaffold-header") { headerContent() } - if (!isRefreshing) return@LazyColumn + if (!isRefreshing) { + // An empty box used to render nothing at all. What a reader searched for before is + // the one thing worth offering there, and it is one tap from being re-run because a + // history entry is stored as the same text the box holds. + if (recent.isNotEmpty()) { + item(key = "recent-header") { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(Res.string.search_recent), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + TextButton(onClick = { searchBarViewModel.history.clearRecent() }) { + Text(stringRes(Res.string.search_recent_clear)) + } + } + } + itemsIndexed(recent, key = { _, item -> "recent-${QuerySerializer.serialize(item)}" }) { _, item -> + val asText = remember(item) { QuerySerializer.serialize(item) } + Text( + text = asText, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .fillMaxWidth() + .clickable { searchBarViewModel.updateSearchValue(asText) } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) + HorizontalDivider(thickness = DividerThickness) + } + } + return@LazyColumn + } // Every result list is empty. Which of the two things that means is not cosmetic: a box // holding only a seeded `kind:` never asked a relay anything, and telling that reader diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index f16f522bf0..f6a67fb6b3 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -2246,6 +2246,8 @@ Try fewer filters, or a different word. Type something to search The filters above narrow what is searched — they do not search on their own. + Recent searches + Clear Reset Source Sort by