feat(desktop): author search in feed builder with relay NIP-50 + avatars

- Hoist author search state to FeedsDrawerTab (AlertDialog can't run LaunchedEffect)
- NIP-50 relay search via connected relays with Channel-based result streaming
- 28dp UserAvatar in author suggestion rows
- "No users found" empty state
- 32dp dialog margins, 300dp max results height, 30 result limit
- FindUsersTest: 8 tests verifying cache search with/without metadata
- Fix: use connectedRelays instead of unconnected DEFAULT_SEARCH_RELAYS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-05-08 07:23:44 +03:00
parent 447f89d2c1
commit 41920a363a
3 changed files with 379 additions and 44 deletions
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
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
@@ -38,6 +39,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.InputChip
import androidx.compose.material3.MaterialTheme
@@ -75,6 +77,11 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
fun FeedBuilderDialog(
initial: FeedDefinition? = null,
localCache: DesktopLocalCache? = null,
authorQuery: String = "",
onAuthorQueryChange: (String) -> Unit = {},
authorSuggestions: List<com.vitorpamplona.amethyst.commons.model.User> = emptyList(),
authorRelayResults: List<com.vitorpamplona.amethyst.commons.model.User> = emptyList(),
authorSearching: Boolean = false,
onSave: (FeedDefinition) -> Unit,
onDismiss: () -> Unit,
) {
@@ -83,7 +90,7 @@ fun FeedBuilderDialog(
AlertDialog(
onDismissRequest = onDismiss,
modifier =
Modifier.onPreviewKeyEvent { event ->
Modifier.padding(horizontal = 32.dp, vertical = 32.dp).onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown &&
event.key == Key.S &&
(event.isMetaPressed || event.isCtrlPressed)
@@ -126,7 +133,15 @@ fun FeedBuilderDialog(
AuthorInputSection(
authors = state.authors,
localCache = localCache,
onAdd = { hex -> if (hex !in state.authors) state.authors.add(hex) },
query = authorQuery,
onQueryChange = onAuthorQueryChange,
suggestions = authorSuggestions,
relayResults = authorRelayResults,
isSearching = authorSearching,
onAdd = { hex ->
if (hex !in state.authors) state.authors.add(hex)
onAuthorQueryChange("")
},
onRemove = { state.authors.remove(it) },
)
@@ -215,6 +230,11 @@ private fun AuthorInputSection(
label: String = "Authors",
authors: List<String>,
localCache: DesktopLocalCache?,
query: String = "",
onQueryChange: (String) -> Unit = {},
suggestions: List<com.vitorpamplona.amethyst.commons.model.User> = emptyList(),
relayResults: List<com.vitorpamplona.amethyst.commons.model.User> = emptyList(),
isSearching: Boolean = false,
onAdd: (String) -> Unit,
onRemove: (String) -> Unit,
) {
@@ -222,7 +242,6 @@ private fun AuthorInputSection(
Text(label, style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(4.dp))
// Show added authors as chips with display names
if (authors.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
@@ -241,30 +260,22 @@ private fun AuthorInputSection(
Spacer(Modifier.height(4.dp))
}
// Search input
var input by remember { mutableStateOf("") }
val suggestions =
remember(input, authors.toList()) {
if (input.length < 2 || localCache == null) {
emptyList()
} else {
localCache
.findUsersStartingWith(input, 8)
.filter { it.pubkeyHex !in authors }
}
}
OutlinedTextField(
value = input,
onValueChange = { input = it },
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search name or paste npub...") },
modifier =
Modifier.fillMaxWidth().onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
if (input.isNotBlank()) {
val hex = decodePublicKeyAsHexOrNull(input.trim()) ?: input.trim()
onAdd(hex)
input = ""
if (query.isNotBlank()) {
val top =
suggestions.firstOrNull { it.pubkeyHex !in authors }
?: relayResults.firstOrNull { it.pubkeyHex !in authors }
if (top != null) {
onAdd(top.pubkeyHex)
} else {
onAdd(decodePublicKeyAsHexOrNull(query.trim()) ?: query.trim())
}
}
true
} else {
@@ -274,45 +285,110 @@ private fun AuthorInputSection(
singleLine = true,
)
// Suggestions dropdown
if (suggestions.isNotEmpty()) {
val filteredLocal = suggestions.filter { it.pubkeyHex !in authors }
val filteredRelay =
relayResults.filter { r ->
r.pubkeyHex !in authors && filteredLocal.none { it.pubkeyHex == r.pubkeyHex }
}
val hasResults = filteredLocal.isNotEmpty() || filteredRelay.isNotEmpty()
val showNoResults = query.length >= 2 && !hasResults && !isSearching
if (hasResults || isSearching || showNoResults) {
Spacer(Modifier.height(4.dp))
Surface(
tonalElevation = 4.dp,
modifier = Modifier.fillMaxWidth().heightIn(max = 160.dp),
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp),
) {
LazyColumn {
items(suggestions, key = { it.pubkeyHex }) { user ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
onAdd(user.pubkeyHex)
input = ""
}.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
user.toBestDisplayName(),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
items(filteredLocal, key = { "c-${it.pubkeyHex}" }) { user ->
AuthorRow(user) { onAdd(user.pubkeyHex) }
}
if (filteredRelay.isNotEmpty()) {
item {
Text(
"From relays",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
items(filteredRelay, key = { "r-${it.pubkeyHex}" }) { user ->
AuthorRow(user) { onAdd(user.pubkeyHex) }
}
}
if (isSearching) {
item {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
Text(
user.pubkeyNpub().take(20) + "...",
"Searching relays...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (showNoResults) {
item {
Text(
"No users found",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(12.dp),
)
}
}
}
}
}
}
}
@Composable
private fun AuthorRow(
user: com.vitorpamplona.amethyst.commons.model.User,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
com.vitorpamplona.amethyst.commons.ui.components.UserAvatar(
userHex = user.pubkeyHex,
pictureUrl = user.profilePicture(),
size = 28.dp,
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
user.toBestDisplayName(),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
user.pubkeyNpub().take(20) + "...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// -- Kind filter checkboxes --
private data class KindOption(
@@ -42,6 +42,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -76,11 +77,112 @@ fun FeedsDrawerTab(
var editingFeed by remember { mutableStateOf<FeedDefinition?>(null) }
var deletingFeed by remember { mutableStateOf<FeedDefinition?>(null) }
// Author search state — hoisted here because AlertDialog can't run LaunchedEffect
var authorQuery by remember { mutableStateOf("") }
var authorLocal by remember {
mutableStateOf(emptyList<com.vitorpamplona.amethyst.commons.model.User>())
}
var authorRelay by remember {
mutableStateOf(emptyList<com.vitorpamplona.amethyst.commons.model.User>())
}
var authorSearching by remember { mutableStateOf(false) }
LaunchedEffect(authorQuery) {
if (authorQuery.length < 2 || localCache == null) {
authorLocal = emptyList()
authorRelay = emptyList()
authorSearching = false
return@LaunchedEffect
}
kotlinx.coroutines.delay(300)
val results = localCache.findUsersStartingWith(authorQuery, 10)
authorLocal = results
if (relayManager != null) {
// Use all connected relays — some support NIP-50 search
val relays = relayManager.connectedRelays.value
if (relays.isNotEmpty()) {
authorSearching = true
authorRelay = emptyList()
val ch = kotlinx.coroutines.channels.Channel<com.vitorpamplona.amethyst.commons.model.User>(64)
val subId =
com.vitorpamplona.amethyst.desktop.subscriptions
.generateSubId("author-search")
relayManager.subscribe(
subId = subId,
filters =
listOf(
com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
.searchPeople(authorQuery, 30),
),
relays = relays,
listener =
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<com.vitorpamplona.quartz.nip01Core.relay.filters.Filter>?,
) {
if (event is com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent) {
localCache.consumeMetadata(event)
localCache.getUserIfExists(event.pubKey)?.let {
ch.trySend(it)
}
}
}
override fun onEose(
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<com.vitorpamplona.quartz.nip01Core.relay.filters.Filter>?,
) {
ch.close()
}
override fun onClosed(
message: String,
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<com.vitorpamplona.quartz.nip01Core.relay.filters.Filter>?,
) {
ch.close()
}
},
)
try {
kotlinx.coroutines.withTimeoutOrNull(8000) {
for (user in ch) {
if (authorRelay.none { it.pubkeyHex == user.pubkeyHex }) {
authorRelay = authorRelay + user
}
}
}
} finally {
authorSearching = false
relayManager.unsubscribe(subId)
}
}
}
}
// Reset search state when dialogs close
LaunchedEffect(showBuilder, editingFeed) {
if (!showBuilder && editingFeed == null) {
authorQuery = ""
authorLocal = emptyList()
authorRelay = emptyList()
authorSearching = false
}
}
// Create dialog
if (showBuilder) {
FeedBuilderDialog(
localCache = localCache,
relayManager = relayManager,
authorQuery = authorQuery,
onAuthorQueryChange = { authorQuery = it },
authorSuggestions = authorLocal,
authorRelayResults = authorRelay,
authorSearching = authorSearching,
onSave = { feed ->
scope.launch { feedRepository.add(feed) }
showBuilder = false
@@ -94,6 +196,11 @@ fun FeedsDrawerTab(
FeedBuilderDialog(
initial = feed,
localCache = localCache,
authorQuery = authorQuery,
onAuthorQueryChange = { authorQuery = it },
authorSuggestions = authorLocal,
authorRelayResults = authorRelay,
authorSearching = authorSearching,
onSave = { updated ->
scope.launch { feedRepository.update(updated) }
editingFeed = null
@@ -0,0 +1,152 @@
/*
* 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.cache
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FindUsersTest {
private fun createCache() = DesktopLocalCache()
private fun fakeMetadata(
pubKey: String,
name: String,
displayName: String = name,
): MetadataEvent =
MetadataEvent(
id = (pubKey.take(16) + "meta").padEnd(64, '0'),
pubKey = pubKey,
createdAt = System.currentTimeMillis() / 1000,
tags = emptyArray(),
content = """{"name":"$name","display_name":"$displayName"}""",
sig = "0".repeat(128),
)
@Test
fun userWithoutMetadataNotFoundByName() {
val cache = createCache()
val pubkey = KeyPair().pubKey.toHexKey()
cache.getOrCreateUser(pubkey)
val results = cache.findUsersStartingWith("test", 10)
assertTrue(results.isEmpty(), "User without metadata should not match name search")
}
@Test
fun userWithMetadataFoundByDisplayName() {
val cache = createCache()
val pubkey = KeyPair().pubKey.toHexKey()
cache.consumeMetadata(fakeMetadata(pubkey, "vitor", "Vitor Pamplona"))
val results = cache.findUsersStartingWith("Vitor", 10)
assertEquals(1, results.size, "Should find user by display name")
assertEquals(pubkey, results[0].pubkeyHex)
}
@Test
fun userWithMetadataFoundByName() {
val cache = createCache()
val pubkey = KeyPair().pubKey.toHexKey()
cache.consumeMetadata(fakeMetadata(pubkey, "vitor"))
val results = cache.findUsersStartingWith("vit", 10)
assertEquals(1, results.size, "Should find user by name prefix")
}
@Test
fun userWithMetadataFoundCaseInsensitive() {
val cache = createCache()
val pubkey = KeyPair().pubKey.toHexKey()
cache.consumeMetadata(fakeMetadata(pubkey, "Vitor", "Vitor Pamplona"))
val lower = cache.findUsersStartingWith("vitor", 10)
assertEquals(1, lower.size, "Should find case-insensitively (lowercase)")
val upper = cache.findUsersStartingWith("VITOR", 10)
assertEquals(1, upper.size, "Should find case-insensitively (uppercase)")
}
@Test
fun userWithoutMetadataFoundByPubkey() {
val cache = createCache()
val pubkey = KeyPair().pubKey.toHexKey()
cache.getOrCreateUser(pubkey)
val results = cache.findUsersStartingWith(pubkey.take(8), 10)
assertEquals(1, results.size, "Should find user by pubkey prefix")
}
@Test
fun multipleUsersWithMetadata() {
val cache = createCache()
cache.consumeMetadata(fakeMetadata(KeyPair().pubKey.toHexKey(), "alice"))
cache.consumeMetadata(fakeMetadata(KeyPair().pubKey.toHexKey(), "bob"))
cache.consumeMetadata(fakeMetadata(KeyPair().pubKey.toHexKey(), "alex"))
val results = cache.findUsersStartingWith("al", 10)
assertEquals(2, results.size, "Should find alice and alex")
}
@Test
fun usersFromNotesWithoutMetadataNotMatchNameSearch() {
val cache = createCache()
// Simulate users created from kind 1 notes (no metadata)
repeat(10) { cache.getOrCreateUser(KeyPair().pubKey.toHexKey()) }
assertEquals(10, cache.userCount())
val results = cache.findUsersStartingWith("test", 10)
assertEquals(0, results.size, "Users without metadata should not match name search")
}
@Test
fun verifyMetadataIsActuallyParsed() {
val cache = createCache()
val pubkey = KeyPair().pubKey.toHexKey()
cache.consumeMetadata(fakeMetadata(pubkey, "testuser", "Test User"))
val user = cache.getUserIfExists(pubkey)
val metadata = user?.metadataOrNull()
assertTrue(metadata != null, "Metadata should exist after consumeMetadata")
assertTrue(
metadata.anyNameOrAddressContains(
listOf(
com.vitorpamplona.quartz.utils
.DualCase("test", "TEST"),
),
),
"Metadata should match 'test' search",
)
}
}