mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat: Android remembers what you searched for
Desktop has had a search history since the advanced bar was written. Android had none, for no reason other than where the code sat — and an empty search box on Android rendered nothing at all, which is the one place a reader's last searches are worth offering. The behaviour came over with `SearchHistory`; what is added here is Android's half. `DataStoreSearchHistoryStorage` is the file the two strings live in — device-global like the drawer's collapse state, since what you searched for is a property of this phone and is never published to a relay. The empty box now lists the recent searches, each one tap from being re-run: an entry is stored as the same text the box holds, so putting it back in the field is the whole of loading it. Recorded on the Enter key rather than on every pause in typing. The field already had an `onSubmit` slot that nothing on Android was passing; recording every settled query would fill the list with the prefixes of the word being typed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
This commit is contained in:
+72
@@ -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<Preferences> 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<String, DataStore<Preferences>>()
|
||||
|
||||
private fun dataStoreFor(file: File): DataStore<Preferences> =
|
||||
stores.computeIfAbsent(file.absolutePath) {
|
||||
PreferenceDataStoreFactory.create(produceFile = { file })
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -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:<npub>` 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 <T : ViewModel> create(modelClass: Class<T>): T = SearchBarViewModel(account, nip05, initialQuery) as T
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchBarViewModel(account, nip05, historyStorage, initialQuery) as T
|
||||
}
|
||||
}
|
||||
|
||||
+48
-1
@@ -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
|
||||
|
||||
@@ -2246,6 +2246,8 @@
|
||||
<string name="search_no_results_explainer">Try fewer filters, or a different word.</string>
|
||||
<string name="search_type_to_begin">Type something to search</string>
|
||||
<string name="search_type_to_begin_explainer">The filters above narrow what is searched — they do not search on their own.</string>
|
||||
<string name="search_recent">Recent searches</string>
|
||||
<string name="search_recent_clear">Clear</string>
|
||||
<string name="search_filters_reset">Reset</string>
|
||||
<string name="search_filters_section_source">Source</string>
|
||||
<string name="search_filters_section_sort">Sort by</string>
|
||||
|
||||
Reference in New Issue
Block a user