Merge pull request #1840 from nrobi144/feat/desktop-advanced-search
feat(desktop): advanced search with NIP-50, collapsible sections, and nav state preservation
This commit is contained in:
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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.desktop
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.search.QueryParser
|
||||
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
|
||||
import com.vitorpamplona.amethyst.commons.search.SavedSearch
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchQuery
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
object SearchHistoryStore {
|
||||
private val prefs: Preferences = Preferences.userNodeForPackage(SearchHistoryStore::class.java)
|
||||
|
||||
private const val KEY_HISTORY = "search_history"
|
||||
private const val KEY_SAVED = "saved_searches"
|
||||
private const val SEPARATOR = "\n"
|
||||
private const val SAVED_SEPARATOR = "\t"
|
||||
private const val MAX_HISTORY = 20
|
||||
|
||||
private val _history = MutableStateFlow<List<SearchQuery>>(emptyList())
|
||||
val history: StateFlow<List<SearchQuery>> = _history.asStateFlow()
|
||||
|
||||
private val _savedSearches = MutableStateFlow<List<SavedSearch>>(emptyList())
|
||||
val savedSearches: StateFlow<List<SavedSearch>> = _savedSearches.asStateFlow()
|
||||
|
||||
init {
|
||||
_history.value = loadHistory()
|
||||
_savedSearches.value = loadSaved()
|
||||
}
|
||||
|
||||
fun addToHistory(query: SearchQuery) {
|
||||
if (query.isEmpty) return
|
||||
val serialized = QuerySerializer.serialize(query)
|
||||
val current = _history.value.toMutableList()
|
||||
current.removeAll { QuerySerializer.serialize(it) == serialized }
|
||||
current.add(0, query)
|
||||
if (current.size > MAX_HISTORY) {
|
||||
current.subList(MAX_HISTORY, current.size).clear()
|
||||
}
|
||||
_history.value = current.toList()
|
||||
persistHistory(current)
|
||||
}
|
||||
|
||||
fun clearHistory() {
|
||||
_history.value = emptyList()
|
||||
prefs.remove(KEY_HISTORY)
|
||||
}
|
||||
|
||||
fun saveSearch(
|
||||
query: SearchQuery,
|
||||
label: String,
|
||||
) {
|
||||
if (query.isEmpty) return
|
||||
val saved =
|
||||
SavedSearch(
|
||||
id = System.currentTimeMillis().toString(),
|
||||
label = label,
|
||||
query = query,
|
||||
createdAt = System.currentTimeMillis() / 1000,
|
||||
)
|
||||
val current = _savedSearches.value + saved
|
||||
_savedSearches.value = current
|
||||
persistSaved(current)
|
||||
}
|
||||
|
||||
fun deleteSavedSearch(id: String) {
|
||||
val current = _savedSearches.value.filter { it.id != id }
|
||||
_savedSearches.value = current
|
||||
persistSaved(current)
|
||||
}
|
||||
|
||||
private fun loadHistory(): List<SearchQuery> {
|
||||
val raw = prefs.get(KEY_HISTORY, "")
|
||||
if (raw.isBlank()) return emptyList()
|
||||
return raw
|
||||
.split(SEPARATOR)
|
||||
.filter { it.isNotBlank() }
|
||||
.mapNotNull { line ->
|
||||
val parsed = QueryParser.parse(line)
|
||||
if (parsed.isEmpty) null else parsed
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistHistory(queries: List<SearchQuery>) {
|
||||
val raw = queries.joinToString(SEPARATOR) { QuerySerializer.serialize(it) }
|
||||
prefs.put(KEY_HISTORY, raw)
|
||||
}
|
||||
|
||||
private fun loadSaved(): List<SavedSearch> {
|
||||
val raw = prefs.get(KEY_SAVED, "")
|
||||
if (raw.isBlank()) return emptyList()
|
||||
return raw
|
||||
.split(SEPARATOR)
|
||||
.filter { it.isNotBlank() }
|
||||
.mapNotNull { line ->
|
||||
val parts = line.split(SAVED_SEPARATOR)
|
||||
if (parts.size < 4) return@mapNotNull null
|
||||
val id = parts[0]
|
||||
val label = parts[1]
|
||||
val createdAt = parts[2].toLongOrNull() ?: return@mapNotNull null
|
||||
val queryText = parts[3]
|
||||
val query = QueryParser.parse(queryText)
|
||||
if (query.isEmpty) return@mapNotNull null
|
||||
SavedSearch(id = id, label = label, query = query, createdAt = createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistSaved(searches: List<SavedSearch>) {
|
||||
val raw =
|
||||
searches.joinToString(SEPARATOR) { s ->
|
||||
listOf(s.id, s.label, s.createdAt.toString(), QuerySerializer.serialize(s.query))
|
||||
.joinToString(SAVED_SEPARATOR)
|
||||
}
|
||||
prefs.put(KEY_SAVED, raw)
|
||||
}
|
||||
}
|
||||
+1
@@ -45,6 +45,7 @@ object DefaultRelays {
|
||||
"wss://nos.lol",
|
||||
"wss://relay.snort.social",
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.noswhere.com",
|
||||
"wss://relay.primal.net",
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -145,6 +145,7 @@ fun createSearchPeopleSubscription(
|
||||
limit: Int = 50,
|
||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||
onClosed: (NormalizedRelayUrl, String, List<Filter>?) -> Unit = { _, _, _ -> },
|
||||
): SubscriptionConfig? {
|
||||
if (searchQuery.isBlank()) return null
|
||||
|
||||
@@ -154,6 +155,7 @@ fun createSearchPeopleSubscription(
|
||||
relays = relays,
|
||||
onEvent = onEvent,
|
||||
onEose = onEose,
|
||||
onClosed = onClosed,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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.desktop.subscriptions
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchQuery
|
||||
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
|
||||
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
|
||||
object SearchFilterFactory {
|
||||
// Default kind groups (ported from Android SearchPostsByText)
|
||||
private val defaultKindGroup1 =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
BadgeDefinitionEvent.KIND,
|
||||
PeopleListEvent.KIND,
|
||||
BookmarkListEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
ChannelCreateEvent.KIND,
|
||||
)
|
||||
|
||||
private val defaultKindGroup2 =
|
||||
listOf(
|
||||
ChannelMetadataEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
CommunityDefinitionEvent.KIND,
|
||||
EmojiPackEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
LiveActivitiesEvent.KIND,
|
||||
PublicMessageEvent.KIND,
|
||||
NNSEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
)
|
||||
|
||||
private val defaultKindGroup3 =
|
||||
listOf(
|
||||
InteractiveStoryPrologueEvent.KIND,
|
||||
InteractiveStorySceneEvent.KIND,
|
||||
FollowListEvent.KIND,
|
||||
NipTextEvent.KIND,
|
||||
PollEvent.KIND,
|
||||
PollResponseEvent.KIND,
|
||||
)
|
||||
|
||||
fun createFilters(
|
||||
query: SearchQuery,
|
||||
limit: Int = 100,
|
||||
): List<Filter> {
|
||||
if (query.isEmpty) return emptyList()
|
||||
|
||||
val searchString = buildSearchString(query)
|
||||
val tags = buildTags(query)
|
||||
val authors = query.authors.takeIf { it.isNotEmpty() }
|
||||
|
||||
if (query.kinds.isNotEmpty()) {
|
||||
// User specified kinds — single filter (no group splitting needed)
|
||||
return listOf(
|
||||
Filter(
|
||||
kinds = query.kinds.toList(),
|
||||
search = searchString,
|
||||
authors = authors,
|
||||
tags = tags,
|
||||
since = query.since,
|
||||
until = query.until,
|
||||
limit = limit,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// No kinds specified — use default 3-group search (Android parity)
|
||||
return listOf(defaultKindGroup1, defaultKindGroup2, defaultKindGroup3).map { kindGroup ->
|
||||
Filter(
|
||||
kinds = kindGroup,
|
||||
search = searchString,
|
||||
authors = authors,
|
||||
tags = tags,
|
||||
since = query.since,
|
||||
until = query.until,
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSearchString(query: SearchQuery): String? {
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
// Free text (exclude negation terms — those are client-side only)
|
||||
if (query.text.isNotBlank()) {
|
||||
parts.add(query.text)
|
||||
}
|
||||
|
||||
// NIP-50 inline extensions
|
||||
query.language?.let { parts.add("language:$it") }
|
||||
query.domain?.let { parts.add("domain:$it") }
|
||||
|
||||
return parts.joinToString(" ").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun buildTags(query: SearchQuery): Map<String, List<String>>? {
|
||||
if (query.hashtags.isEmpty()) return null
|
||||
return mapOf("t" to query.hashtags.toList())
|
||||
}
|
||||
}
|
||||
+9
@@ -46,6 +46,7 @@ data class SubscriptionConfig(
|
||||
val relays: Set<NormalizedRelayUrl>,
|
||||
val onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||
val onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||
val onClosed: (NormalizedRelayUrl, String, List<Filter>?) -> Unit = { _, _, _ -> },
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -95,6 +96,14 @@ fun rememberSubscription(
|
||||
) {
|
||||
cfg.onEose(relay, forFilters)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
message: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
cfg.onClosed(relay, message, forFilters)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+456
-223
@@ -20,6 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -37,39 +42,68 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
|
||||
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
|
||||
import com.vitorpamplona.amethyst.commons.search.SavedSearch
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchQuery
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchResult
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.SearchBarState
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchResultFilter
|
||||
import com.vitorpamplona.amethyst.commons.search.parseSearchInput
|
||||
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.SearchFilterFactory
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
|
||||
@@ -85,61 +119,140 @@ fun SearchScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val searchState = remember { SearchBarState(localCache, scope) }
|
||||
val state = remember { AdvancedSearchBarState(scope) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
// Pre-fill initial query (e.g., hashtag column)
|
||||
// Pre-fill initial query
|
||||
LaunchedEffect(initialQuery) {
|
||||
if (initialQuery.isNotBlank()) {
|
||||
searchState.updateSearchText(initialQuery)
|
||||
state.updateFromText(initialQuery)
|
||||
}
|
||||
}
|
||||
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val allRelayUrls = remember(relayStatuses) { relayStatuses.keys }
|
||||
val displayText by state.displayText.collectAsState()
|
||||
// Track TextFieldValue locally to preserve cursor position
|
||||
var textFieldValue by remember { mutableStateOf(TextFieldValue(displayText)) }
|
||||
// Sync from flow only when text changes externally (form-driven updates)
|
||||
LaunchedEffect(displayText) {
|
||||
if (textFieldValue.text != displayText) {
|
||||
textFieldValue = TextFieldValue(text = displayText, selection = TextRange(displayText.length))
|
||||
}
|
||||
}
|
||||
val query by state.query.collectAsState()
|
||||
val debouncedQuery by state.debouncedQuery.collectAsState()
|
||||
val panelExpanded by state.panelExpanded.collectAsState()
|
||||
val isSearching by state.isSearching.collectAsState()
|
||||
val peopleResults by state.peopleResults.collectAsState()
|
||||
val noteResults by state.noteResults.collectAsState()
|
||||
val relayStates by state.relayStates.collectAsState()
|
||||
|
||||
// Collect state from SearchBarState
|
||||
val searchText by searchState.searchText.collectAsState()
|
||||
val bech32Results by searchState.bech32Results.collectAsState()
|
||||
val cachedUserResults by searchState.cachedUserResults.collectAsState()
|
||||
val relaySearchResults by searchState.relaySearchResults.collectAsState()
|
||||
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
|
||||
// Bech32 parsing (immediate, no debounce)
|
||||
val bech32Results = remember(displayText) { parseSearchInput(displayText) }
|
||||
|
||||
// NIP-50 relay search when local cache has few/no results
|
||||
rememberSubscription(connectedRelays, searchText, cachedUserResults.size, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty()) return@rememberSubscription null
|
||||
// Skip people search when query specifies kinds that don't include profile (kind 0)
|
||||
val shouldSearchPeople =
|
||||
(debouncedQuery.kinds.isEmpty() && debouncedQuery.pseudoKinds.isEmpty()) ||
|
||||
debouncedQuery.kinds.contains(MetadataEvent.KIND)
|
||||
|
||||
// Only search relays if we have a real query and limited local results
|
||||
if (searchState.shouldSearchRelays) {
|
||||
searchState.startRelaySearch()
|
||||
createSearchPeopleSubscription(
|
||||
relays = connectedRelays,
|
||||
searchQuery = searchText,
|
||||
limit = 20,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is MetadataEvent) {
|
||||
localCache.consumeMetadata(event)
|
||||
val user = localCache.getUserIfExists(event.pubKey)
|
||||
if (user != null) {
|
||||
searchState.addRelaySearchResult(user)
|
||||
}
|
||||
}
|
||||
},
|
||||
onEose = { _, _ ->
|
||||
searchState.endRelaySearch()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
// Clear results and start loading when query changes
|
||||
LaunchedEffect(debouncedQuery) {
|
||||
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
|
||||
state.clearResults()
|
||||
state.initRelayStates(allRelayUrls)
|
||||
if (shouldSearchPeople) {
|
||||
state.startSearching("people-search")
|
||||
}
|
||||
state.startSearching("adv-search")
|
||||
// Timeout relays that silently ignore NIP-50 (e.g. strfry)
|
||||
kotlinx.coroutines.delay(10_000L)
|
||||
state.timeoutWaitingRelays()
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to metadata for searched users (to populate cache)
|
||||
rememberSubscription(connectedRelays, searchText, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || searchText.length < 2) {
|
||||
// NIP-50 people search subscription (use allRelayUrls — openReqSubscription will connect)
|
||||
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
|
||||
if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
if (bech32Results.isNotEmpty()) return@rememberSubscription null
|
||||
if (!shouldSearchPeople) {
|
||||
state.stopSearching("people-search")
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
// If it's a specific pubkey search, fetch that user's metadata
|
||||
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
|
||||
createSearchPeopleSubscription(
|
||||
relays = allRelayUrls,
|
||||
searchQuery =
|
||||
debouncedQuery.text.ifBlank {
|
||||
QuerySerializer.serialize(debouncedQuery)
|
||||
},
|
||||
limit = 20,
|
||||
onEvent = { event, _, relay, _ ->
|
||||
if (state.trackRelayEvent(relay.url, event.id)) {
|
||||
if (event is MetadataEvent) {
|
||||
localCache.consumeMetadata(event)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val user = localCache.getUserIfExists(event.pubKey) as? User
|
||||
if (user != null) {
|
||||
state.addPeopleResult(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onEose = { relay, _ ->
|
||||
state.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED)
|
||||
state.stopSearching("people-search")
|
||||
},
|
||||
onClosed = { relay, _, _ ->
|
||||
state.updateRelayState(relay.url, RelaySyncStatus.FAILED)
|
||||
state.stopSearching("people-search")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// NIP-50 advanced note search subscription (use allRelayUrls)
|
||||
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
|
||||
if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
if (bech32Results.isNotEmpty()) return@rememberSubscription null
|
||||
|
||||
val filters = SearchFilterFactory.createFilters(debouncedQuery)
|
||||
if (filters.isEmpty()) return@rememberSubscription null
|
||||
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("adv-search"),
|
||||
filters = filters,
|
||||
relays = allRelayUrls,
|
||||
onEvent = { event, _, relay, _ ->
|
||||
if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig
|
||||
if (state.trackRelayEvent(relay.url, event.id)) {
|
||||
val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery)
|
||||
if (filtered.isNotEmpty()) {
|
||||
state.addNoteResults(filtered)
|
||||
}
|
||||
}
|
||||
},
|
||||
onEose = { relay, _ ->
|
||||
state.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED)
|
||||
state.stopSearching("adv-search")
|
||||
},
|
||||
onClosed = { relay, _, _ ->
|
||||
state.updateRelayState(relay.url, RelaySyncStatus.FAILED)
|
||||
state.stopSearching("adv-search")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Metadata subscription for bech32 pubkey lookups
|
||||
rememberSubscription(connectedRelays, displayText, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || displayText.length < 2) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
val pubkeyHex = decodePublicKeyAsHexOrNull(displayText)
|
||||
if (pubkeyHex != null) {
|
||||
createMetadataSubscription(
|
||||
relays = connectedRelays,
|
||||
@@ -155,14 +268,67 @@ fun SearchScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-focus the search field
|
||||
// Save to history when search completes (snapshotFlow avoids LaunchedEffect race)
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { isSearching to debouncedQuery }
|
||||
.collect { (searching, query) ->
|
||||
if (!searching && !query.isEmpty) {
|
||||
SearchHistoryStore.addToHistory(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// History state
|
||||
val historyItems by SearchHistoryStore.history.collectAsState()
|
||||
val savedSearches by SearchHistoryStore.savedSearches.collectAsState()
|
||||
|
||||
// Auto-focus
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when (event.key) {
|
||||
Key.Escape -> {
|
||||
if (panelExpanded) {
|
||||
state.togglePanel()
|
||||
} else if (displayText.isNotEmpty()) {
|
||||
state.clearSearch()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Progress bar at very top
|
||||
AnimatedVisibility(
|
||||
visible = isSearching,
|
||||
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Relay status banner
|
||||
SearchSyncBanner(
|
||||
relayStates = relayStates,
|
||||
isSearching = isSearching,
|
||||
)
|
||||
|
||||
// Title row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -182,180 +348,265 @@ fun SearchScreen(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Search input field
|
||||
OutlinedTextField(
|
||||
value = searchText,
|
||||
onValueChange = { searchState.updateSearchText(it) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
placeholder = { Text("Search by name, npub, nevent, or #hashtag") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = "Search",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchText.isNotEmpty()) {
|
||||
IconButton(onClick = { searchState.clearSearch() }) {
|
||||
Icon(
|
||||
Icons.Default.Clear,
|
||||
contentDescription = "Clear",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Search bar with advanced toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
textFieldValue = it
|
||||
state.updateFromText(it.text)
|
||||
},
|
||||
modifier = Modifier.weight(1f).focusRequester(focusRequester),
|
||||
placeholder = { Text("Search notes, people, tags... or use operators") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = "Search",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayText.isNotEmpty()) {
|
||||
IconButton(onClick = { state.clearSearch() }) {
|
||||
Icon(
|
||||
Icons.Default.Clear,
|
||||
contentDescription = "Clear",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
IconButton(onClick = { state.togglePanel() }) {
|
||||
Icon(
|
||||
Icons.Default.Tune,
|
||||
contentDescription = "Advanced Search",
|
||||
tint =
|
||||
if (panelExpanded) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Expandable advanced panel
|
||||
AnimatedVisibility(
|
||||
visible = panelExpanded,
|
||||
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
|
||||
) {
|
||||
AdvancedSearchPanel(
|
||||
query = query,
|
||||
onKindsChanged = { state.updateKinds(it) },
|
||||
onPseudoKindsChanged = { state.updatePseudoKinds(it) },
|
||||
onAuthorAdded = { state.addAuthor(it) },
|
||||
onAuthorRemoved = { state.removeAuthor(it) },
|
||||
onDateRangeChanged = { since, until -> state.updateDateRange(since, until) },
|
||||
onHashtagAdded = { state.addHashtag(it) },
|
||||
onHashtagRemoved = { state.removeHashtag(it) },
|
||||
onExcludeAdded = { state.addExcludeTerm(it) },
|
||||
onExcludeRemoved = { state.removeExcludeTerm(it) },
|
||||
onLanguageChanged = { state.updateLanguage(it) },
|
||||
onClear = { state.clearSearch() },
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Results
|
||||
val hasResults = bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty() || relaySearchResults.isNotEmpty()
|
||||
val hasAnyResults =
|
||||
bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty()
|
||||
|
||||
if (!hasResults && searchText.isNotEmpty() && searchText.length >= 2 && !isSearchingRelays) {
|
||||
if (bech32Results.isNotEmpty()) {
|
||||
// Show bech32 results (exact lookup)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"Direct lookup",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
bech32Results.forEach { result ->
|
||||
SearchResultCard(
|
||||
result = result,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onNavigateToHashtag = onNavigateToHashtag,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (hasAnyResults) {
|
||||
SearchResultsList(
|
||||
state = state,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
)
|
||||
} else if (!debouncedQuery.isEmpty && !isSearching) {
|
||||
Text(
|
||||
"No matches found. Try a name, npub, nevent, or #hashtag.",
|
||||
"No results found. Try broader terms or fewer filters.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else if (isSearchingRelays && !hasResults) {
|
||||
Text(
|
||||
"Searching relays...",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
} else if (!isSearching) {
|
||||
// Empty state: show history + saved searches + operator hints
|
||||
SearchEmptyState(
|
||||
historyItems = historyItems,
|
||||
savedSearches = savedSearches,
|
||||
onLoadQuery = { query -> state.updateFromText(QuerySerializer.serialize(query)) },
|
||||
onDeleteSaved = { id -> SearchHistoryStore.deleteSavedSearch(id) },
|
||||
onClearHistory = { SearchHistoryStore.clearHistory() },
|
||||
)
|
||||
} else if (hasResults) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Bech32/hex results first
|
||||
if (bech32Results.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Direct lookup",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
items(bech32Results) { result ->
|
||||
SearchResultCard(
|
||||
result = result,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onNavigateToHashtag = onNavigateToHashtag,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Cached user results
|
||||
if (cachedUserResults.isNotEmpty()) {
|
||||
if (bech32Results.isNotEmpty()) {
|
||||
item {
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
"Cached users (${cachedUserResults.size})",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
items(cachedUserResults, key = { "cached-${it.pubkeyHex}" }) { user ->
|
||||
UserSearchCard(
|
||||
user = user,
|
||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Relay search results (NIP-50)
|
||||
if (relaySearchResults.isNotEmpty()) {
|
||||
if (bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty()) {
|
||||
item {
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"From relays (${relaySearchResults.size})",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
if (isSearchingRelays) {
|
||||
Text(
|
||||
"searching...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(relaySearchResults, key = { "relay-${it.pubkeyHex}" }) { user ->
|
||||
UserSearchCard(
|
||||
user = user,
|
||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||
)
|
||||
}
|
||||
} else if (isSearchingRelays && cachedUserResults.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Searching relays...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Empty state
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"Search for users or notes",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Enter a name or Nostr identifier:",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
SearchHint("vitor", "Search by name")
|
||||
SearchHint("npub1...", "User profile")
|
||||
SearchHint("note1...", "Single note")
|
||||
SearchHint("nevent1...", "Note with metadata")
|
||||
SearchHint("#hashtag", "Hashtag search")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchEmptyState(
|
||||
historyItems: List<SearchQuery>,
|
||||
savedSearches: List<SavedSearch>,
|
||||
onLoadQuery: (SearchQuery) -> Unit,
|
||||
onDeleteSaved: (String) -> Unit,
|
||||
onClearHistory: () -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// Saved searches
|
||||
if (savedSearches.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Saved Searches",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
items(savedSearches, key = { "saved-${it.id}" }) { saved ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onLoadQuery(saved.query) }
|
||||
.padding(vertical = 6.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
saved.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
QuerySerializer.serialize(saved.query),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onDeleteSaved(saved.id) }) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
|
||||
}
|
||||
|
||||
// Recent history
|
||||
if (historyItems.isNotEmpty()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Recent",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = onClearHistory) {
|
||||
Text("Clear", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(historyItems.take(10), key = { "history-${QuerySerializer.serialize(it)}" }) { query ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onLoadQuery(query) }
|
||||
.padding(vertical = 6.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.History,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
QuerySerializer.serialize(query),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
|
||||
}
|
||||
|
||||
// Operator hints
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = if (historyItems.isEmpty() && savedSearches.isEmpty()) 32.dp else 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"Search operators",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
item { SearchHint("from:npub1...", "Filter by author") }
|
||||
item { SearchHint("kind:article", "Long-form content") }
|
||||
item { SearchHint("since:2025-01", "After January 2025") }
|
||||
item { SearchHint("#bitcoin", "Hashtag search") }
|
||||
item { SearchHint("\"exact phrase\"", "Exact match") }
|
||||
item { SearchHint("bitcoin OR nostr", "Either term") }
|
||||
item { SearchHint("-spam", "Exclude term") }
|
||||
item { SearchHint("lang:en", "Language filter") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchHint(
|
||||
identifier: String,
|
||||
example: String,
|
||||
description: String,
|
||||
) {
|
||||
Row(
|
||||
@@ -363,7 +614,7 @@ private fun SearchHint(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
identifier,
|
||||
example,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
@@ -390,25 +641,10 @@ private fun SearchResultCard(
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
when (result) {
|
||||
is SearchResult.UserResult -> {
|
||||
onNavigateToProfile(result.pubKeyHex)
|
||||
}
|
||||
|
||||
is SearchResult.CachedUserResult -> {
|
||||
onNavigateToProfile(result.user.pubkeyHex)
|
||||
}
|
||||
|
||||
is SearchResult.NoteResult -> {
|
||||
onNavigateToThread(result.noteIdHex)
|
||||
}
|
||||
|
||||
is SearchResult.AddressResult -> {
|
||||
onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}")
|
||||
}
|
||||
|
||||
is SearchResult.HashtagResult -> {
|
||||
onNavigateToHashtag(result.hashtag)
|
||||
}
|
||||
is SearchResult.UserResult -> onNavigateToProfile(result.pubKeyHex)
|
||||
is SearchResult.NoteResult -> onNavigateToThread(result.noteIdHex)
|
||||
is SearchResult.AddressResult -> onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}")
|
||||
is SearchResult.HashtagResult -> onNavigateToHashtag(result.hashtag)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
@@ -425,7 +661,6 @@ private fun SearchResultCard(
|
||||
imageVector =
|
||||
when (result) {
|
||||
is SearchResult.UserResult -> Icons.Default.Person
|
||||
is SearchResult.CachedUserResult -> Icons.Default.Person
|
||||
is SearchResult.NoteResult -> Icons.Default.Description
|
||||
is SearchResult.AddressResult -> Icons.Default.Description
|
||||
is SearchResult.HashtagResult -> Icons.Default.Tag
|
||||
@@ -439,7 +674,6 @@ private fun SearchResultCard(
|
||||
Text(
|
||||
when (result) {
|
||||
is SearchResult.UserResult -> "User Profile"
|
||||
is SearchResult.CachedUserResult -> result.user.toBestDisplayName()
|
||||
is SearchResult.NoteResult -> "Note"
|
||||
is SearchResult.AddressResult -> "Event (kind ${result.kind})"
|
||||
is SearchResult.HashtagResult -> "#${result.hashtag}"
|
||||
@@ -450,7 +684,6 @@ private fun SearchResultCard(
|
||||
Text(
|
||||
when (result) {
|
||||
is SearchResult.UserResult -> result.displayId
|
||||
is SearchResult.CachedUserResult -> result.user.pubkeyDisplayHex()
|
||||
is SearchResult.NoteResult -> result.displayId
|
||||
is SearchResult.AddressResult -> result.displayId
|
||||
is SearchResult.HashtagResult -> "Search posts with this hashtag"
|
||||
|
||||
+37
-30
@@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -120,37 +122,42 @@ fun DeckColumnContainer(
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||
) {
|
||||
// Always keep RootContent composed so state (e.g. search results) survives navigation
|
||||
RootContent(
|
||||
columnType = column.type,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||
)
|
||||
if (currentOverlay != null) {
|
||||
OverlayContent(
|
||||
screen = currentOverlay,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||
onBack = { navState.pop() },
|
||||
)
|
||||
} else {
|
||||
RootContent(
|
||||
columnType = column.type,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||
)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
OverlayContent(
|
||||
screen = currentOverlay,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||
onBack = { navState.pop() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-85
@@ -20,20 +20,16 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.deck
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Article
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
@@ -43,12 +39,11 @@ import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -57,7 +52,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -156,90 +150,47 @@ fun SinglePaneLayout(
|
||||
VerticalDivider()
|
||||
|
||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
// Show header with back button when navigated into overlay
|
||||
if (navStack.isNotEmpty()) {
|
||||
SinglePaneHeader(
|
||||
title =
|
||||
when (currentOverlay) {
|
||||
is DesktopScreen.UserProfile -> "Profile"
|
||||
is DesktopScreen.Thread -> "Thread"
|
||||
else -> currentColumnType.title()
|
||||
},
|
||||
onBack = { navState.pop() },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||
) {
|
||||
// Always keep RootContent composed so state (e.g. search results) survives navigation
|
||||
RootContent(
|
||||
columnType = currentColumnType,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||
)
|
||||
if (currentOverlay != null) {
|
||||
OverlayContent(
|
||||
screen = currentOverlay,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||
onBack = { navState.pop() },
|
||||
)
|
||||
} else {
|
||||
RootContent(
|
||||
columnType = currentColumnType,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||
)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
OverlayContent(
|
||||
screen = currentOverlay,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||
onBack = { navState.pop() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SinglePaneHeader(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
/*
|
||||
* 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.desktop.ui.search
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.search.ContentPreset
|
||||
import com.vitorpamplona.amethyst.commons.search.DateUtils
|
||||
import com.vitorpamplona.amethyst.commons.search.KindRegistry
|
||||
import com.vitorpamplona.amethyst.commons.search.QueryParser
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchQuery
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun AdvancedSearchPanel(
|
||||
query: SearchQuery,
|
||||
onKindsChanged: (List<Int>) -> Unit,
|
||||
onPseudoKindsChanged: (List<String>) -> Unit,
|
||||
onAuthorAdded: (String) -> Unit,
|
||||
onAuthorRemoved: (String) -> Unit,
|
||||
onDateRangeChanged: (Long?, Long?) -> Unit,
|
||||
onHashtagAdded: (String) -> Unit,
|
||||
onHashtagRemoved: (String) -> Unit,
|
||||
onExcludeAdded: (String) -> Unit,
|
||||
onExcludeRemoved: (String) -> Unit,
|
||||
onLanguageChanged: (String?) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Content type presets
|
||||
Text(
|
||||
"Content Type",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
KindRegistry.presets.forEach { (name, preset) ->
|
||||
FilterChip(
|
||||
selected = preset.isSelected(query.kinds.toList(), query.pseudoKinds.toList()),
|
||||
onClick = { togglePreset(preset, query, onKindsChanged, onPseudoKindsChanged) },
|
||||
label = { Text(name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Author field
|
||||
AuthorInputField(
|
||||
authors = query.authors.toList() + query.authorNames.toList(),
|
||||
onAuthorAdded = onAuthorAdded,
|
||||
onAuthorRemoved = onAuthorRemoved,
|
||||
)
|
||||
|
||||
// Date range
|
||||
DateRangeFields(
|
||||
since = query.since,
|
||||
until = query.until,
|
||||
onChanged = onDateRangeChanged,
|
||||
)
|
||||
|
||||
// Hashtags
|
||||
ChipGroupWithInput(
|
||||
label = "Tags",
|
||||
items = query.hashtags.toList(),
|
||||
prefix = "#",
|
||||
placeholder = "Add tag...",
|
||||
onAdd = onHashtagAdded,
|
||||
onRemove = onHashtagRemoved,
|
||||
)
|
||||
|
||||
// Exclude terms
|
||||
ChipGroupWithInput(
|
||||
label = "Exclude",
|
||||
items = query.excludeTerms.toList(),
|
||||
prefix = "-",
|
||||
placeholder = "Exclude term...",
|
||||
onAdd = onExcludeAdded,
|
||||
onRemove = onExcludeRemoved,
|
||||
)
|
||||
|
||||
// Language
|
||||
LanguageSelector(
|
||||
selected = query.language,
|
||||
onChanged = onLanguageChanged,
|
||||
)
|
||||
|
||||
// Clear button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
OutlinedButton(onClick = onClear) {
|
||||
Text("Clear All")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun togglePreset(
|
||||
preset: ContentPreset,
|
||||
query: SearchQuery,
|
||||
onKindsChanged: (List<Int>) -> Unit,
|
||||
onPseudoKindsChanged: (List<String>) -> Unit,
|
||||
) {
|
||||
val pseudo = preset.pseudoKind
|
||||
if (pseudo != null) {
|
||||
val current = query.pseudoKinds.toList()
|
||||
if (pseudo in current) {
|
||||
onPseudoKindsChanged(current - pseudo)
|
||||
} else {
|
||||
onPseudoKindsChanged(current + pseudo)
|
||||
}
|
||||
} else {
|
||||
val current = query.kinds.toList()
|
||||
if (current.containsAll(preset.kinds)) {
|
||||
onKindsChanged(current - preset.kinds.toSet())
|
||||
} else {
|
||||
onKindsChanged((current + preset.kinds).distinct())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun AuthorInputField(
|
||||
authors: List<String>,
|
||||
onAuthorAdded: (String) -> Unit,
|
||||
onAuthorRemoved: (String) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"Author",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (authors.isNotEmpty()) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
authors.forEach { author ->
|
||||
AssistChip(
|
||||
onClick = { onAuthorRemoved(author) },
|
||||
label = {
|
||||
Text(
|
||||
if (author.length > 16) author.take(8) + "..." + author.takeLast(4) else author,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(Icons.Default.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
var authorInput by remember { mutableStateOf("") }
|
||||
OutlinedTextField(
|
||||
value = authorInput,
|
||||
onValueChange = { authorInput = it },
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().onKeyEvent {
|
||||
if (it.key == Key.Enter && authorInput.isNotBlank()) {
|
||||
onAuthorAdded(authorInput.trim())
|
||||
authorInput = ""
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
placeholder = { Text("npub or name...") },
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
if (authorInput.isNotBlank()) {
|
||||
IconButton(onClick = {
|
||||
onAuthorAdded(authorInput.trim())
|
||||
authorInput = ""
|
||||
}) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add author")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DateRangeFields(
|
||||
since: Long?,
|
||||
until: Long?,
|
||||
onChanged: (Long?, Long?) -> Unit,
|
||||
) {
|
||||
// Local text is source of truth while typing.
|
||||
// Only propagate valid timestamps (or null when cleared).
|
||||
// Only sync from external when the timestamp changes to something we didn't produce.
|
||||
var sinceText by remember { mutableStateOf(since?.let { DateUtils.timestampToDate(it) } ?: "") }
|
||||
var lastSince by remember { mutableStateOf(since) }
|
||||
if (since != lastSince) {
|
||||
sinceText = since?.let { DateUtils.timestampToDate(it) } ?: ""
|
||||
lastSince = since
|
||||
}
|
||||
|
||||
var untilText by remember { mutableStateOf(until?.let { DateUtils.timestampToDate(it) } ?: "") }
|
||||
var lastUntil by remember { mutableStateOf(until) }
|
||||
if (until != lastUntil) {
|
||||
untilText = until?.let { DateUtils.timestampToDate(it) } ?: ""
|
||||
lastUntil = until
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Since",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = sinceText,
|
||||
onValueChange = {
|
||||
sinceText = it
|
||||
val ts = QueryParser.parseDateToTimestamp(it)
|
||||
if (ts != null || it.isBlank()) {
|
||||
lastSince = ts
|
||||
onChanged(ts, until)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("YYYY-MM-DD") },
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Until",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = untilText,
|
||||
onValueChange = {
|
||||
untilText = it
|
||||
val ts = QueryParser.parseDateToTimestamp(it)
|
||||
if (ts != null || it.isBlank()) {
|
||||
lastUntil = ts
|
||||
onChanged(since, ts)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("YYYY-MM-DD") },
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ChipGroupWithInput(
|
||||
label: String,
|
||||
items: List<String>,
|
||||
prefix: String,
|
||||
placeholder: String,
|
||||
onAdd: (String) -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items.forEach { item ->
|
||||
AssistChip(
|
||||
onClick = { onRemove(item) },
|
||||
label = { Text("$prefix$item", style = MaterialTheme.typography.bodySmall) },
|
||||
trailingIcon = {
|
||||
Icon(Icons.Default.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (items.isNotEmpty()) Spacer(Modifier.height(4.dp))
|
||||
var inputText by remember { mutableStateOf("") }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { inputText = it },
|
||||
modifier =
|
||||
Modifier.weight(1f).onKeyEvent {
|
||||
if (it.key == Key.Enter && inputText.isNotBlank()) {
|
||||
onAdd(inputText.trim())
|
||||
inputText = ""
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
placeholder = { Text(placeholder) },
|
||||
singleLine = true,
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (inputText.isNotBlank()) {
|
||||
onAdd(inputText.trim())
|
||||
inputText = ""
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Add")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageSelector(
|
||||
selected: String?,
|
||||
onChanged: (String?) -> Unit,
|
||||
) {
|
||||
val languages =
|
||||
listOf(
|
||||
null to "Any",
|
||||
"en" to "English",
|
||||
"es" to "Spanish",
|
||||
"pt" to "Portuguese",
|
||||
"ja" to "Japanese",
|
||||
"zh" to "Chinese",
|
||||
"de" to "German",
|
||||
"fr" to "French",
|
||||
)
|
||||
|
||||
Column {
|
||||
Text(
|
||||
"Language",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
languages.forEach { (code, name) ->
|
||||
FilterChip(
|
||||
selected = selected == code,
|
||||
onClick = { onChanged(code) },
|
||||
label = { Text(name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
/*
|
||||
* 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.desktop.ui.search
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Article
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Forum
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
|
||||
import com.vitorpamplona.amethyst.commons.search.KindRegistry
|
||||
import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
|
||||
@Composable
|
||||
fun SearchResultsList(
|
||||
state: AdvancedSearchBarState,
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
onNavigateToThread: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
) {
|
||||
val people by state.sortedPeopleResults.collectAsState()
|
||||
val notes by state.sortedNoteResults.collectAsState()
|
||||
val eventSortOrder by state.eventSortOrder.collectAsState()
|
||||
val peopleSortOrder by state.peopleSortOrder.collectAsState()
|
||||
|
||||
val hasResults = people.isNotEmpty() || notes.isNotEmpty()
|
||||
|
||||
if (!hasResults) return
|
||||
|
||||
// Group notes by kind
|
||||
val textNotes = notes.filter { it.kind == 1 }
|
||||
val articles = notes.filter { it.kind == LongTextNoteEvent.KIND }
|
||||
val otherNotes = notes.filter { it.kind != 1 && it.kind != LongTextNoteEvent.KIND }
|
||||
|
||||
// Per-section collapsed state (absent = expanded)
|
||||
val collapsedSections = remember { mutableStateMapOf<String, Boolean>() }
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
// People section
|
||||
if (people.isNotEmpty()) {
|
||||
val collapsed = collapsedSections["people"] == true
|
||||
stickyHeader(key = "header-people") {
|
||||
SortableHeader(
|
||||
title = "People",
|
||||
count = people.size,
|
||||
icon = Icons.Default.Person,
|
||||
options = SearchSortOrder.PEOPLE_OPTIONS,
|
||||
selected = peopleSortOrder,
|
||||
onSelect = { state.updatePeopleSortOrder(it) },
|
||||
collapsed = collapsed,
|
||||
onToggleCollapse = { collapsedSections["people"] = !collapsed },
|
||||
)
|
||||
}
|
||||
if (!collapsed) {
|
||||
val displayPeople = people.take(5)
|
||||
items(displayPeople, key = { "person-${it.pubkeyHex}" }) { user ->
|
||||
UserSearchCard(
|
||||
user = user,
|
||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||
)
|
||||
}
|
||||
if (people.size > 5) {
|
||||
item(key = "people-expand") {
|
||||
ExpandableSection(
|
||||
remaining = people.drop(5),
|
||||
) { user ->
|
||||
UserSearchCard(
|
||||
user = user,
|
||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notes section
|
||||
if (textNotes.isNotEmpty()) {
|
||||
if (people.isNotEmpty()) {
|
||||
item(key = "divider-notes") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
|
||||
}
|
||||
val collapsed = collapsedSections["notes"] == true
|
||||
stickyHeader(key = "header-notes") {
|
||||
SortableHeader(
|
||||
title = "Notes",
|
||||
count = textNotes.size,
|
||||
icon = Icons.Default.Description,
|
||||
options = SearchSortOrder.EVENT_OPTIONS,
|
||||
selected = eventSortOrder,
|
||||
onSelect = { state.updateEventSortOrder(it) },
|
||||
collapsed = collapsed,
|
||||
onToggleCollapse = { collapsedSections["notes"] = !collapsed },
|
||||
)
|
||||
}
|
||||
if (!collapsed) {
|
||||
val displayNotes = textNotes.take(5)
|
||||
items(displayNotes, key = { "note-${it.id}" }) { event ->
|
||||
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||
}
|
||||
if (textNotes.size > 5) {
|
||||
item(key = "notes-expand") {
|
||||
ExpandableSection(
|
||||
remaining = textNotes.drop(5),
|
||||
) { event ->
|
||||
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Articles section
|
||||
if (articles.isNotEmpty()) {
|
||||
if (people.isNotEmpty() || textNotes.isNotEmpty()) {
|
||||
item(key = "divider-articles") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
|
||||
}
|
||||
val collapsed = collapsedSections["articles"] == true
|
||||
stickyHeader(key = "header-articles") {
|
||||
SortableHeader(
|
||||
title = "Articles",
|
||||
count = articles.size,
|
||||
icon = Icons.Default.Article,
|
||||
options = SearchSortOrder.EVENT_OPTIONS,
|
||||
selected = eventSortOrder,
|
||||
onSelect = { state.updateEventSortOrder(it) },
|
||||
collapsed = collapsed,
|
||||
onToggleCollapse = { collapsedSections["articles"] = !collapsed },
|
||||
)
|
||||
}
|
||||
if (!collapsed) {
|
||||
items(articles.take(5), key = { "article-${it.id}" }) { event ->
|
||||
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||
}
|
||||
if (articles.size > 5) {
|
||||
item(key = "articles-expand") {
|
||||
ExpandableSection(
|
||||
remaining = articles.drop(5),
|
||||
) { event ->
|
||||
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Other section
|
||||
if (otherNotes.isNotEmpty()) {
|
||||
item(key = "divider-other") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
|
||||
val collapsed = collapsedSections["other"] == true
|
||||
stickyHeader(key = "header-other") {
|
||||
SortableHeader(
|
||||
title = "Other",
|
||||
count = otherNotes.size,
|
||||
icon = Icons.Default.Forum,
|
||||
options = SearchSortOrder.EVENT_OPTIONS,
|
||||
selected = eventSortOrder,
|
||||
onSelect = { state.updateEventSortOrder(it) },
|
||||
collapsed = collapsed,
|
||||
onToggleCollapse = { collapsedSections["other"] = !collapsed },
|
||||
)
|
||||
}
|
||||
if (!collapsed) {
|
||||
items(otherNotes.take(5), key = { "other-${it.id}" }) { event ->
|
||||
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom padding
|
||||
item(key = "bottom-spacer") { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SortableHeader(
|
||||
title: String,
|
||||
count: Int,
|
||||
icon: ImageVector,
|
||||
options: List<SearchSortOrder>,
|
||||
selected: SearchSortOrder,
|
||||
onSelect: (SearchSortOrder) -> Unit,
|
||||
collapsed: Boolean = false,
|
||||
onToggleCollapse: () -> Unit = {},
|
||||
) {
|
||||
val chevronRotation by animateFloatAsState(if (collapsed) -90f else 0f)
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable(onClick = onToggleCollapse).padding(vertical = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ExpandMore,
|
||||
contentDescription = if (collapsed) "Expand $title" else "Collapse $title",
|
||||
modifier = Modifier.size(18.dp).rotate(chevronRotation),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(start = 4.dp).size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
"$title ($count)",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (!collapsed) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
options.forEach { option ->
|
||||
FilterChip(
|
||||
selected = option == selected,
|
||||
onClick = { onSelect(option) },
|
||||
label = {
|
||||
Text(
|
||||
option.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
},
|
||||
colors =
|
||||
FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
modifier = Modifier.height(28.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotePreviewCard(
|
||||
event: Event,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Kind badge
|
||||
val kindName = KindRegistry.nameFor(event.kind) ?: "kind ${event.kind}"
|
||||
Text(
|
||||
kindName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
// Author (hex truncated)
|
||||
Text(
|
||||
event.pubKey.take(8) + "..." + event.pubKey.takeLast(4),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
// Timestamp
|
||||
Text(
|
||||
event.createdAt.toTimeAgo(withDot = false),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// Content preview
|
||||
Text(
|
||||
event.content.take(200),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ExpandableSection(
|
||||
remaining: List<T>,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
if (expanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
remaining.forEach { item ->
|
||||
content(item)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = { expanded = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Default.ExpandMore, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text("Show all ${remaining.size} more")
|
||||
}
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* 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.desktop.ui.search
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.HourglassEmpty
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.chess.RelaySyncState
|
||||
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
fun SearchSyncBanner(
|
||||
relayStates: ImmutableList<RelaySyncState>,
|
||||
isSearching: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isVisible = isSearching || relayStates.isNotEmpty()
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column {
|
||||
// Collapsed summary row
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { isExpanded = !isExpanded }
|
||||
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
) {
|
||||
val responded = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED }
|
||||
val total = relayStates.size
|
||||
val totalEvents = relayStates.sumOf { it.eventsReceived }
|
||||
|
||||
Text(
|
||||
text =
|
||||
if (total > 0) {
|
||||
"$responded/$total relays responded \u00B7 $totalEvents events"
|
||||
} else {
|
||||
"Connecting to relays..."
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Expanded per-relay details
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded && relayStates.isNotEmpty(),
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Column {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
thickness = 0.5.dp,
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
) {
|
||||
relayStates.forEach { relay ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = relayStatusIcon(relay.status),
|
||||
contentDescription = null,
|
||||
tint = relayStatusColor(relay.status),
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Text(
|
||||
text = relay.displayName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = "${relay.eventsReceived} events",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun relayStatusIcon(status: RelaySyncStatus): ImageVector =
|
||||
when (status) {
|
||||
RelaySyncStatus.CONNECTING -> Icons.Default.HourglassEmpty
|
||||
RelaySyncStatus.WAITING -> Icons.Default.HourglassEmpty
|
||||
RelaySyncStatus.RECEIVING -> Icons.Default.CloudDownload
|
||||
RelaySyncStatus.EOSE_RECEIVED -> Icons.Default.CheckCircle
|
||||
RelaySyncStatus.FAILED -> Icons.Default.Error
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun relayStatusColor(status: RelaySyncStatus): Color =
|
||||
when (status) {
|
||||
RelaySyncStatus.CONNECTING -> MaterialTheme.colorScheme.secondary
|
||||
RelaySyncStatus.WAITING -> MaterialTheme.colorScheme.secondary
|
||||
RelaySyncStatus.RECEIVING -> MaterialTheme.colorScheme.primary
|
||||
RelaySyncStatus.EOSE_RECEIVED -> MaterialTheme.colorScheme.primary
|
||||
RelaySyncStatus.FAILED -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
Reference in New Issue
Block a user