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:
Vitor Pamplona
2026-03-16 08:14:18 -04:00
committed by GitHub
29 changed files with 4999 additions and 347 deletions
@@ -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)
}
}
@@ -45,6 +45,7 @@ object DefaultRelays {
"wss://nos.lol",
"wss://relay.snort.social",
"wss://nostr.wine",
"wss://relay.noswhere.com",
"wss://relay.primal.net",
)
}
@@ -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,
)
}
@@ -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())
}
}
@@ -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)
}
},
)
}
@@ -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"
@@ -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() },
)
}
}
}
}
@@ -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,
)
}
}
@@ -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) },
)
}
}
}
}
@@ -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")
}
}
}
@@ -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
}