feat(search): collapsible section headers + sorting + search improvements

- Collapsible sticky section headers with animated chevron
- Full header row clickable to toggle collapse
- SearchResultSorter, SearchSortOrder, pseudo-kind filtering
- TextFieldValue cursor preservation, relay timeout improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-12 13:23:27 +02:00
parent 7180edf162
commit 5a437ed5ac
11 changed files with 1849 additions and 137 deletions
@@ -64,8 +64,10 @@ 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
@@ -131,6 +133,14 @@ fun SearchScreen(
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()
@@ -142,12 +152,19 @@ fun SearchScreen(
// Bech32 parsing (immediate, no debounce)
val bech32Results = remember(displayText) { parseSearchInput(displayText) }
// 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)
// Clear results and start loading when query changes
LaunchedEffect(debouncedQuery) {
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
state.clearResults()
state.initRelayStates(allRelayUrls)
state.startSearching("people-search")
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)
@@ -161,6 +178,10 @@ fun SearchScreen(
return@rememberSubscription null
}
if (bech32Results.isNotEmpty()) return@rememberSubscription null
if (!shouldSearchPeople) {
state.stopSearching("people-search")
return@rememberSubscription null
}
createSearchPeopleSubscription(
relays = allRelayUrls,
@@ -334,12 +355,11 @@ fun SearchScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value =
TextFieldValue(
text = displayText,
selection = TextRange(displayText.length),
),
onValueChange = { state.updateFromText(it.text) },
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 = {
@@ -386,6 +406,7 @@ fun SearchScreen(
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) },
@@ -56,6 +56,7 @@ 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
@@ -66,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.search.SearchQuery
fun AdvancedSearchPanel(
query: SearchQuery,
onKindsChanged: (List<Int>) -> Unit,
onPseudoKindsChanged: (List<String>) -> Unit,
onAuthorAdded: (String) -> Unit,
onAuthorRemoved: (String) -> Unit,
onDateRangeChanged: (Long?, Long?) -> Unit,
@@ -99,10 +101,10 @@ fun AdvancedSearchPanel(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
KindRegistry.presets.forEach { (name, kinds) ->
KindRegistry.presets.forEach { (name, preset) ->
FilterChip(
selected = query.kinds.toList().containsAll(kinds),
onClick = { onKindsChanged(toggleKinds(query.kinds.toList(), kinds)) },
selected = preset.isSelected(query.kinds.toList(), query.pseudoKinds.toList()),
onClick = { togglePreset(preset, query, onKindsChanged, onPseudoKindsChanged) },
label = { Text(name) },
)
}
@@ -161,15 +163,29 @@ fun AdvancedSearchPanel(
}
}
private fun toggleKinds(
current: List<Int>,
toggle: List<Int>,
): List<Int> =
if (current.containsAll(toggle)) {
current - toggle.toSet()
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 {
(current + toggle).distinct()
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
@@ -242,19 +258,21 @@ private fun DateRangeFields(
until: Long?,
onChanged: (Long?, Long?) -> Unit,
) {
var sinceText by remember(since) {
mutableStateOf(
since?.let {
DateUtils.timestampToDate(it)
} ?: "",
)
// 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(until) {
mutableStateOf(
until?.let {
DateUtils.timestampToDate(it)
} ?: "",
)
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(
@@ -271,9 +289,11 @@ private fun DateRangeFields(
value = sinceText,
onValueChange = {
sinceText = it
val ts =
QueryParser.parseDateToTimestamp(it)
onChanged(ts, until)
val ts = QueryParser.parseDateToTimestamp(it)
if (ts != null || it.isBlank()) {
lastSince = ts
onChanged(ts, until)
}
},
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("YYYY-MM-DD") },
@@ -290,9 +310,11 @@ private fun DateRangeFields(
value = untilText,
onValueChange = {
untilText = it
val ts =
QueryParser.parseDateToTimestamp(it)
onChanged(since, ts)
val ts = QueryParser.parseDateToTimestamp(it)
if (ts != null || it.isBlank()) {
lastUntil = ts
onChanged(since, ts)
}
},
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("YYYY-MM-DD") },
@@ -20,6 +20,7 @@
*/
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
@@ -41,6 +42,8 @@ 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
@@ -50,17 +53,20 @@ 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
@@ -74,8 +80,10 @@ fun SearchResultsList(
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
) {
val people by state.peopleResults.collectAsState()
val notes by state.noteResults.collectAsState()
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()
@@ -86,6 +94,9 @@ fun SearchResultsList(
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),
@@ -93,25 +104,37 @@ fun SearchResultsList(
) {
// People section
if (people.isNotEmpty()) {
val collapsed = collapsedSections["people"] == true
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) },
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 (people.size > 5) {
item(key = "people-expand") {
ExpandableSection(
remaining = people.drop(5),
) { user ->
UserSearchCard(
user = user,
onClick = { onNavigateToProfile(user.pubkeyHex) },
)
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) },
)
}
}
}
}
@@ -122,19 +145,31 @@ fun SearchResultsList(
if (people.isNotEmpty()) {
item(key = "divider-notes") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
}
val collapsed = collapsedSections["notes"] == true
stickyHeader(key = "header-notes") {
SectionHeader("Notes", textNotes.size, Icons.Default.Description)
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 },
)
}
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) })
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) })
}
}
}
}
@@ -145,18 +180,30 @@ fun SearchResultsList(
if (people.isNotEmpty() || textNotes.isNotEmpty()) {
item(key = "divider-articles") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
}
val collapsed = collapsedSections["articles"] == true
stickyHeader(key = "header-articles") {
SectionHeader("Articles", articles.size, Icons.Default.Article)
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 },
)
}
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) })
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) })
}
}
}
}
@@ -165,11 +212,23 @@ fun SearchResultsList(
// Other section
if (otherNotes.isNotEmpty()) {
item(key = "divider-other") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
val collapsed = collapsedSections["other"] == true
stickyHeader(key = "header-other") {
SectionHeader("Other", otherNotes.size, Icons.Default.Forum)
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 },
)
}
items(otherNotes.take(5), key = { "other-${it.id}" }) { event ->
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
if (!collapsed) {
items(otherNotes.take(5), key = { "other-${it.id}" }) { event ->
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
}
}
}
@@ -179,31 +238,67 @@ fun SearchResultsList(
}
@Composable
private fun SectionHeader(
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,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(vertical = 8.dp),
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.size(18.dp),
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),
)
}
}
}
}
}
}