From 41920a363ade73937d422d57932aadc95a50a0f7 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 8 May 2026 07:23:44 +0300 Subject: [PATCH] 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) --- .../desktop/ui/deck/FeedBuilderDialog.kt | 162 +++++++++++++----- .../desktop/ui/deck/FeedsDrawerTab.kt | 109 +++++++++++- .../amethyst/desktop/cache/FindUsersTest.kt | 152 ++++++++++++++++ 3 files changed, 379 insertions(+), 44 deletions(-) create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/FindUsersTest.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedBuilderDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedBuilderDialog.kt index f4b7a5e57..d76c59611 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedBuilderDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedBuilderDialog.kt @@ -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 = emptyList(), + authorRelayResults: List = 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, localCache: DesktopLocalCache?, + query: String = "", + onQueryChange: (String) -> Unit = {}, + suggestions: List = emptyList(), + relayResults: List = 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( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt index 1cd53ccbb..2551d756d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt @@ -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(null) } var deletingFeed by remember { mutableStateOf(null) } + // Author search state — hoisted here because AlertDialog can't run LaunchedEffect + var authorQuery by remember { mutableStateOf("") } + var authorLocal by remember { + mutableStateOf(emptyList()) + } + var authorRelay by remember { + mutableStateOf(emptyList()) + } + 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(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?, + ) { + 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?, + ) { + ch.close() + } + + override fun onClosed( + message: String, + relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + forFilters: List?, + ) { + 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 diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/FindUsersTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/FindUsersTest.kt new file mode 100644 index 000000000..ed79daee0 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/FindUsersTest.kt @@ -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", + ) + } +}