From de879b9472b2eeb6b142152428153b5275b03aa2 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 8 May 2026 07:32:20 +0300 Subject: [PATCH] fix(desktop): use SearchBarState + rememberSubscription for author search Replace manual relay subscribe + Channel approach with the proven SearchBarState + rememberSubscription pattern (same as NewDmDialog). This properly handles relay connection lifecycle and NIP-50 search, returning results from all connected relays. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../loggedIn/settings/AllSettingsScreen.kt | 599 ++++++++++++------ .../desktop/ui/deck/FeedsDrawerTab.kt | 137 ++-- 2 files changed, 448 insertions(+), 288 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index f040d037e..a6cec1a38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -20,46 +20,52 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import android.widget.Toast +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AutoAwesome -import androidx.compose.material.icons.outlined.Bolt -import androidx.compose.material.icons.outlined.CloudUpload -import androidx.compose.material.icons.outlined.DeleteForever -import androidx.compose.material.icons.outlined.FavoriteBorder -import androidx.compose.material.icons.outlined.GroupAdd -import androidx.compose.material.icons.outlined.History -import androidx.compose.material.icons.outlined.Key -import androidx.compose.material.icons.outlined.MilitaryTech -import androidx.compose.material.icons.outlined.Phone -import androidx.compose.material.icons.outlined.Search -import androidx.compose.material.icons.outlined.Security -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.Sync -import androidx.compose.material.icons.outlined.ThumbUp -import androidx.compose.material.icons.outlined.Translate +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +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.Scaffold 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.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -69,6 +75,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Preview @Composable @@ -86,223 +94,418 @@ fun AllSettingsScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val tint = MaterialTheme.colorScheme.onBackground + val context = LocalContext.current + val scope = rememberCoroutineScope() + var showResetMarmotDialog by remember { mutableStateOf(false) } + var isResettingMarmot by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + val hasPrivateKey = accountViewModel.account.settings.keyPair.privKey != null Scaffold( topBar = { - TopBarWithBackButton(stringRes(id = R.string.settings), nav::popBack) + TopBarWithBackButton(stringRes(id = R.string.settings), nav) + }, + bottomBar = { + AppBottomBar(Route.AllSettings, nav, accountViewModel) { route -> + if (route == Route.AllSettings) { + scope.launch { scrollState.animateScrollTo(0) } + } else { + nav.navBottomBar(route) + } + } }, ) { padding -> - Column(Modifier.padding(padding).verticalScroll(rememberScrollState())) { - SettingsSectionHeader(R.string.account_settings) - SettingsNavigationRow( - title = R.string.relay_setup, - iconPainter = R.drawable.relays, - iconPainterRef = 4, - tint = tint, - onClick = { nav.nav(Route.EditRelays) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.event_sync_title, - icon = Icons.Outlined.Sync, - tint = tint, - onClick = { nav.nav(Route.EventSync) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.route_import_follows, - icon = Icons.Outlined.GroupAdd, - tint = tint, - onClick = { nav.nav(Route.ImportFollowsSelectUser) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.media_servers, - icon = Icons.Outlined.CloudUpload, - tint = tint, - onClick = { nav.nav(Route.EditMediaServers) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.profile_badges_title, - icon = Icons.Outlined.MilitaryTech, - tint = tint, - onClick = { nav.nav(Route.ProfileBadges) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.favorite_dvms_title, - icon = Icons.Outlined.AutoAwesome, - tint = tint, - onClick = { nav.nav(Route.EditFavoriteAlgoFeeds) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.reactions, - icon = Icons.Outlined.FavoriteBorder, - tint = tint, - onClick = { nav.nav(Route.UpdateReactionType) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.zaps, - icon = Icons.Outlined.Bolt, - tint = tint, - onClick = { nav.nav(Route.UpdateZapAmount()) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.security_filters, - icon = Icons.Outlined.Security, - tint = tint, - onClick = { nav.nav(Route.SecurityFilters) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.call_settings, - icon = Icons.Outlined.Phone, - tint = tint, - onClick = { nav.nav(Route.CallSettings) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.translations, - icon = Icons.Outlined.Translate, - tint = tint, - onClick = { nav.nav(Route.UserSettings) }, - ) - HorizontalDivider(thickness = 4.dp) - SettingsSectionHeader(R.string.app_settings) - SettingsNavigationRow( - title = R.string.privacy_options, - iconPainter = R.drawable.ic_tor, - iconPainterRef = 1, - tint = tint, - onClick = { nav.nav(Route.PrivacyOptions) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.ots_explorer_settings, - icon = Icons.Outlined.Search, - tint = tint, - onClick = { nav.nav(Route.OtsSettings) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.namecoin_settings, - icon = Icons.Outlined.Security, - tint = tint, - onClick = { nav.nav(Route.NamecoinSettings) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.ui_preferences, - icon = Icons.Outlined.Settings, - tint = tint, - onClick = { nav.nav(Route.Settings) }, - ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.reactions_settings, - icon = Icons.Outlined.ThumbUp, - tint = tint, - onClick = { nav.nav(Route.ReactionsSettings) }, - ) - HorizontalDivider(thickness = 4.dp) - SettingsSectionHeader(R.string.danger_zone) - accountViewModel.account.settings.keyPair.privKey?.let { - SettingsNavigationRow( - title = R.string.backup_keys, - icon = Icons.Outlined.Key, - tint = tint, - onClick = { nav.nav(Route.AccountBackup) }, + Column( + modifier = + Modifier + .padding(padding) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + SettingsSection(R.string.account_settings) { + SettingsItem( + title = R.string.relay_setup, + iconPainter = R.drawable.relays, + iconPainterRef = 4, + onClick = { nav.nav(Route.EditRelays) }, ) - HorizontalDivider() - SettingsNavigationRow( - title = R.string.request_to_vanish, - icon = Icons.Outlined.DeleteForever, - tint = tint, - onClick = { nav.nav(Route.RequestToVanish) }, + SettingsDivider() + SettingsItem( + title = R.string.event_sync_title, + icon = MaterialSymbols.Sync, + onClick = { nav.nav(Route.EventSync) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.route_import_follows, + icon = MaterialSymbols.GroupAdd, + onClick = { nav.nav(Route.ImportFollowsSelectUser) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.media_servers, + icon = MaterialSymbols.CloudUpload, + onClick = { nav.nav(Route.EditMediaServers) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.nests_servers_title, + icon = MaterialSymbols.CloudUpload, + onClick = { nav.nav(Route.EditNestsServers) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.profile_badges_title, + icon = MaterialSymbols.MilitaryTech, + onClick = { nav.nav(Route.ProfileBadges) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.favorite_dvms_title, + icon = MaterialSymbols.AutoAwesome, + onClick = { nav.nav(Route.EditFavoriteAlgoFeeds) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.reactions, + icon = MaterialSymbols.FavoriteBorder, + onClick = { nav.nav(Route.UpdateReactionType) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.video_player_settings, + icon = MaterialSymbols.VideoSettings, + onClick = { nav.nav(Route.VideoPlayerSettings) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.zaps, + icon = MaterialSymbols.Bolt, + onClick = { nav.nav(Route.UpdateZapAmount()) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.payment_targets, + icon = MaterialSymbols.Payment, + onClick = { nav.nav(Route.EditPaymentTargets) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.security_filters, + icon = MaterialSymbols.Security, + onClick = { nav.nav(Route.SecurityFilters) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.call_settings, + icon = MaterialSymbols.Phone, + onClick = { nav.nav(Route.CallSettings) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.translations, + icon = MaterialSymbols.Translate, + onClick = { nav.nav(Route.UserSettings) }, ) - HorizontalDivider() } - SettingsNavigationRow( - title = R.string.vanish_history, - icon = Icons.Outlined.History, - tint = tint, - onClick = { nav.nav(Route.VanishEvents) }, + + SettingsSection(R.string.app_settings) { + SettingsItem( + title = R.string.privacy_options, + iconPainter = R.drawable.ic_tor, + iconPainterRef = 1, + onClick = { nav.nav(Route.PrivacyOptions) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.ots_explorer_settings, + icon = MaterialSymbols.Search, + onClick = { nav.nav(Route.OtsSettings) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.namecoin_settings, + icon = MaterialSymbols.Security, + onClick = { nav.nav(Route.NamecoinSettings) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.ui_preferences, + icon = MaterialSymbols.Settings, + onClick = { nav.nav(Route.Settings) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.reactions_settings, + icon = MaterialSymbols.ThumbUp, + onClick = { nav.nav(Route.ReactionsSettings) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.bottom_bar_settings, + icon = MaterialSymbols.Dashboard, + onClick = { nav.nav(Route.BottomBarSettings) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.home_tabs_settings, + icon = MaterialSymbols.Home, + onClick = { nav.nav(Route.HomeTabsSettings) }, + ) + } + + SettingsSection(R.string.danger_zone, isDanger = true) { + if (hasPrivateKey) { + SettingsItem( + title = R.string.backup_keys, + icon = MaterialSymbols.Key, + isDanger = true, + onClick = { nav.nav(Route.AccountBackup) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.request_to_vanish, + icon = MaterialSymbols.DeleteForever, + isDanger = true, + onClick = { nav.nav(Route.RequestToVanish) }, + ) + SettingsDivider() + } + SettingsItem( + title = R.string.vanish_history, + icon = MaterialSymbols.History, + isDanger = true, + onClick = { nav.nav(Route.VanishEvents) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.reset_marmot_state, + icon = MaterialSymbols.DeleteSweep, + isDanger = true, + onClick = { if (!isResettingMarmot) showResetMarmotDialog = true }, + ) + } + } + } + + if (showResetMarmotDialog) { + ResetMarmotStateDialog( + onConfirm = { + showResetMarmotDialog = false + isResettingMarmot = true + scope.launch(Dispatchers.IO) { + val successMessage = stringRes(context, R.string.reset_marmot_success) + try { + accountViewModel.resetMarmotState() + launch(Dispatchers.Main) { + Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + val failureMessage = + stringRes(context, R.string.reset_marmot_failure, e.message ?: "") + launch(Dispatchers.Main) { + Toast.makeText(context, failureMessage, Toast.LENGTH_LONG).show() + } + } finally { + isResettingMarmot = false + } + } + }, + onDismiss = { showResetMarmotDialog = false }, + ) + } +} + +@Composable +private fun ResetMarmotStateDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + symbol = MaterialSymbols.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp), ) + }, + title = { + Text( + text = stringRes(R.string.reset_marmot_confirm_title), + textAlign = TextAlign.Center, + ) + }, + text = { + Text(text = stringRes(R.string.reset_marmot_confirm_body)) + }, + confirmButton = { + Button( + onClick = onConfirm, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringRes(R.string.reset_marmot_confirm_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} + +@Composable +private fun SettingsSection( + title: Int, + isDanger: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringRes(title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = + if (isDanger) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + modifier = Modifier.padding(horizontal = 4.dp), + ) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column(content = content) } } } @Composable -private fun SettingsSectionHeader(title: Int) { - Text( - text = stringRes(title), - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), +private fun SettingsDivider() { + HorizontalDivider( + modifier = Modifier.padding(start = 68.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant, ) } @Composable -private fun SettingsNavigationRow( +private fun SettingsItem( title: Int, - icon: ImageVector, - tint: Color, + icon: MaterialSymbol, + isDanger: Boolean = false, onClick: () -> Unit, ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(vertical = 16.dp, horizontal = 24.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = stringRes(title), - modifier = Modifier.size(24.dp), - tint = tint, - ) - Text( - text = stringRes(title), - fontSize = 18.sp, - modifier = Modifier.padding(start = 16.dp), - ) - } + SettingsItemRow( + title = title, + isDanger = isDanger, + onClick = onClick, + leadingIcon = { tint -> + Icon( + symbol = icon, + contentDescription = stringRes(title), + modifier = Modifier.size(20.dp), + tint = tint, + ) + }, + ) } @Composable -private fun SettingsNavigationRow( +private fun SettingsItem( title: Int, iconPainter: Int, iconPainterRef: Int, - tint: Color, + isDanger: Boolean = false, onClick: () -> Unit, ) { + val painter: Painter = painterRes(iconPainter, iconPainterRef) + SettingsItemRow( + title = title, + isDanger = isDanger, + onClick = onClick, + leadingIcon = { tint -> + Icon( + painter = painter, + contentDescription = stringRes(title), + modifier = Modifier.size(20.dp), + tint = tint, + ) + }, + ) +} + +@Composable +private fun SettingsItemRow( + title: Int, + isDanger: Boolean, + onClick: () -> Unit, + leadingIcon: @Composable (tint: Color) -> Unit, +) { + val containerColor = + if (isDanger) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + } + val iconTint = + if (isDanger) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer + } + val textColor = + if (isDanger) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + } + Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(vertical = 16.dp, horizontal = 24.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - painter = painterRes(iconPainter, iconPainterRef), - contentDescription = stringRes(title), - modifier = Modifier.size(24.dp), - tint = tint, - ) + Box( + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(containerColor), + contentAlignment = Alignment.Center, + ) { + leadingIcon(iconTint) + } Text( text = stringRes(title), - fontSize = 18.sp, - modifier = Modifier.padding(start = 16.dp), + style = MaterialTheme.typography.bodyLarge, + color = textColor, + modifier = + Modifier + .weight(1f) + .padding(start = 16.dp), + ) + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } 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 2551d756d..bcfc46cd5 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 @@ -77,100 +77,57 @@ 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) - } + // Author search — same pattern as NewDmDialog: SearchBarState + rememberSubscription + val searchState = + remember(localCache) { + localCache?.let { + com.vitorpamplona.amethyst.commons.viewmodels + .SearchBarState(it, scope) } } + val authorQuery = searchState?.searchText?.collectAsState()?.value ?: "" + val authorLocal = searchState?.cachedUserResults?.collectAsState()?.value ?: emptyList() + val authorRelay = searchState?.relaySearchResults?.collectAsState()?.value ?: emptyList() + val authorSearching = searchState?.isSearchingRelays?.collectAsState()?.value ?: false + + // NIP-50 relay search — fires when local cache has few results (same as NewDmDialog) + if (relayManager != null && searchState != null) { + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = relayStatuses.keys + + com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription( + connectedRelays, + authorQuery, + authorLocal.size, + relayManager = relayManager, + ) { + if (connectedRelays.isEmpty()) return@rememberSubscription null + if (!searchState.shouldSearchRelays) return@rememberSubscription null + + searchState.startRelaySearch() + com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription( + relays = connectedRelays, + searchQuery = authorQuery, + limit = 30, + onEvent = { event, _, _, _ -> + if (event is com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent && + localCache != null + ) { + localCache.consumeMetadata(event) + localCache.getUserIfExists(event.pubKey)?.let { + searchState.addRelaySearchResult(it) + } + } + }, + onEose = { _, _ -> searchState.endRelaySearch() }, + ) + } } - // Reset search state when dialogs close + // Reset search when dialogs close LaunchedEffect(showBuilder, editingFeed) { if (!showBuilder && editingFeed == null) { - authorQuery = "" - authorLocal = emptyList() - authorRelay = emptyList() - authorSearching = false + searchState?.clearSearch() } } @@ -179,7 +136,7 @@ fun FeedsDrawerTab( FeedBuilderDialog( localCache = localCache, authorQuery = authorQuery, - onAuthorQueryChange = { authorQuery = it }, + onAuthorQueryChange = { searchState?.updateSearchText(it) }, authorSuggestions = authorLocal, authorRelayResults = authorRelay, authorSearching = authorSearching, @@ -197,7 +154,7 @@ fun FeedsDrawerTab( initial = feed, localCache = localCache, authorQuery = authorQuery, - onAuthorQueryChange = { authorQuery = it }, + onAuthorQueryChange = { searchState?.updateSearchText(it) }, authorSuggestions = authorLocal, authorRelayResults = authorRelay, authorSearching = authorSearching,