feat(search): add advanced search UI with panel, results list, and SearchScreen rewrite

Phase 4: Desktop UI composables for advanced search.
- Rewrite SearchScreen.kt to use AdvancedSearchBarState with TextFieldValue
- Add AdvancedSearchPanel with kind presets, author chips, date range, hashtags, language
- Add SearchResultsList with sticky headers for People/Notes/Articles/Other sections
- Make QueryParser.parseDateToTimestamp and QuerySerializer.timestampToDate public

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-10 12:12:17 +02:00
parent 8265d3f70a
commit 55154dddb2
5 changed files with 978 additions and 227 deletions
@@ -280,7 +280,7 @@ object QueryParser {
)
}
internal fun parseDateToTimestamp(dateStr: String): Long? {
fun parseDateToTimestamp(dateStr: String): Long? {
// ISO 8601 formats: YYYY, YYYY-MM, YYYY-MM-DD
return try {
val parts = dateStr.split("-")
@@ -84,7 +84,7 @@ object QuerySerializer {
return parts.joinToString(" ")
}
internal fun timestampToDate(timestamp: Long): String {
fun timestampToDate(timestamp: Long): String {
// Convert unix timestamp to YYYY-MM-DD
var remaining = timestamp
var year = 1970
@@ -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
@@ -31,8 +36,6 @@ 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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
@@ -41,9 +44,9 @@ import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
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.MaterialTheme
@@ -59,17 +62,26 @@ 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.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.model.User
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
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.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.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
@@ -85,66 +97,101 @@ fun SearchScreen(
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val searchState = remember { SearchBarState(localCache, scope) }
val state = remember { AdvancedSearchBarState(localCache, 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 relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
val displayText by state.displayText.collectAsState()
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()
// 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(relayStatuses, searchText, cachedUserResults.size, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty()) return@rememberSubscription null
// Only search relays if we have a real query and limited local results
if (searchState.shouldSearchRelays) {
searchState.startRelaySearch()
createSearchPeopleSubscription(
relays = configuredRelays,
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
}
}
// Subscribe to metadata for searched users (to populate cache)
rememberSubscription(relayStatuses, searchText, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || searchText.length < 2) {
// NIP-50 people search subscription
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
if (connectedRelays.isEmpty() || debouncedQuery.isEmpty) {
return@rememberSubscription null
}
// Skip if bech32 detected
if (bech32Results.isNotEmpty()) return@rememberSubscription null
// If it's a specific pubkey search, fetch that user's metadata
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
state.startSearching()
state.clearResults()
createSearchPeopleSubscription(
relays = connectedRelays,
searchQuery =
debouncedQuery.text.ifBlank {
com.vitorpamplona.amethyst.commons.search.QuerySerializer
.serialize(debouncedQuery)
},
limit = 20,
onEvent = { event, _, _, _ ->
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 = { _, _ ->
state.stopSearching()
},
)
}
// NIP-50 advanced note search subscription (kinds beyond people)
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
if (connectedRelays.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 = connectedRelays,
onEvent = { event, _, _, _ ->
// Skip metadata events (handled by people search)
if (event.kind == 0) return@SubscriptionConfig
val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery)
if (filtered.isNotEmpty()) {
state.addNoteResults(filtered)
}
},
onEose = { _, _ ->
state.stopSearching()
},
)
}
// 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 = configuredRelays,
relays = connectedRelays,
pubKeyHex = pubkeyHex,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
@@ -157,14 +204,12 @@ fun SearchScreen(
}
}
// Auto-focus the search field
// Auto-focus
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Column(
modifier = modifier.fillMaxSize(),
) {
Column(modifier = modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -184,173 +229,157 @@ 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(
text = displayText,
selection = TextRange(displayText.length),
),
onValueChange = { 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) },
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) {
} else if (isSearching) {
Text(
"Searching relays...",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
} 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")
}
}
// Empty state with operator hints
EmptySearchHints()
}
}
}
@Composable
private fun EmptySearchHints() {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Search for users, notes, or content",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Text(
"Use operators to refine your search:",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
SearchHint("from:npub1...", "Filter by author")
SearchHint("kind:article", "Long-form content")
SearchHint("since:2025-01", "After January 2025")
SearchHint("#bitcoin", "Hashtag search")
SearchHint("\"exact phrase\"", "Exact match")
SearchHint("bitcoin OR nostr", "Either term")
SearchHint("-spam", "Exclude term")
SearchHint("lang:en", "Language filter")
}
}
}
@@ -392,25 +421,11 @@ 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.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)
}
},
colors =
@@ -0,0 +1,409 @@
/*
* 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.KindRegistry
import com.vitorpamplona.amethyst.commons.search.SearchQuery
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AdvancedSearchPanel(
query: SearchQuery,
onKindsChanged: (List<Int>) -> 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, kinds) ->
FilterChip(
selected = query.kinds.toList().containsAll(kinds),
onClick = { onKindsChanged(toggleKinds(query.kinds.toList(), kinds)) },
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 toggleKinds(
current: List<Int>,
toggle: List<Int>,
): List<Int> =
if (current.containsAll(toggle)) {
current - toggle.toSet()
} else {
(current + toggle).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,
) {
var sinceText by remember(since) {
mutableStateOf(
since?.let {
com.vitorpamplona.amethyst.commons.search.QuerySerializer
.timestampToDate(it)
} ?: "",
)
}
var untilText by remember(until) {
mutableStateOf(
until?.let {
com.vitorpamplona.amethyst.commons.search.QuerySerializer
.timestampToDate(it)
} ?: "",
)
}
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 =
com.vitorpamplona.amethyst.commons.search.QueryParser
.parseDateToTimestamp(it)
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 =
com.vitorpamplona.amethyst.commons.search.QueryParser
.parseDateToTimestamp(it)
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,327 @@
/*
* 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.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.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.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.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.ui.components.UserSearchCard
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.peopleResults.collectAsState()
val notes by state.noteResults.collectAsState()
val isSearching by state.isSearching.collectAsState()
val hasResults = people.isNotEmpty() || notes.isNotEmpty()
if (!hasResults && isSearching) {
Text(
"Searching relays...",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = modifier.padding(vertical = 8.dp),
)
return
}
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 }
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
// People section
if (people.isNotEmpty()) {
stickyHeader(key = "header-people") {
SectionHeader("People", people.size, Icons.Default.Person)
}
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),
key = { "person-more-${it.pubkeyHex}" },
) { 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)) }
}
stickyHeader(key = "header-notes") {
SectionHeader("Notes", textNotes.size, Icons.Default.Description)
}
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),
key = { "note-more-${it.id}" },
) { 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)) }
}
stickyHeader(key = "header-articles") {
SectionHeader("Articles", articles.size, Icons.Default.Article)
}
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),
key = { "article-more-${it.id}" },
) { event ->
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
}
}
}
}
// Other section
if (otherNotes.isNotEmpty()) {
item(key = "divider-other") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
stickyHeader(key = "header-other") {
SectionHeader("Other", otherNotes.size, Icons.Default.Forum)
}
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 SectionHeader(
title: String,
count: Int,
icon: ImageVector,
) {
Surface(
color = MaterialTheme.colorScheme.background,
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(vertical = 8.dp),
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
"$title ($count)",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@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(
formatTimestamp(event.createdAt),
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>,
key: (T) -> String,
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")
}
}
}
private fun formatTimestamp(ts: Long): String {
val now = System.currentTimeMillis() / 1000
val diff = now - ts
return when {
diff < 60 -> {
"just now"
}
diff < 3600 -> {
"${diff / 60}m ago"
}
diff < 86400 -> {
"${diff / 3600}h ago"
}
diff < 604800 -> {
"${diff / 86400}d ago"
}
else -> {
// Simple date format
val days = ts / 86400
val year = 1970 + (days / 365).toInt()
"$year"
}
}
}