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:
+1
-1
@@ -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("-")
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+240
-225
@@ -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 =
|
||||
|
||||
+409
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+327
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user