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 a6cec1a38..f040d037e 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,52 +20,46 @@ */ 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.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.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.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.painter.Painter -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.vector.ImageVector 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 @@ -75,8 +69,6 @@ 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 @@ -94,418 +86,223 @@ fun AllSettingsScreen( accountViewModel: AccountViewModel, nav: INav, ) { - 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 + val tint = MaterialTheme.colorScheme.onBackground Scaffold( topBar = { - 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) - } - } + TopBarWithBackButton(stringRes(id = R.string.settings), nav::popBack) }, ) { padding -> - 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) }, - ) - 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) }, - ) - } - - 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 SettingsDivider() { - HorizontalDivider( - modifier = Modifier.padding(start = 68.dp), - thickness = 0.5.dp, - color = MaterialTheme.colorScheme.outlineVariant, - ) -} - -@Composable -private fun SettingsItem( - title: Int, - icon: MaterialSymbol, - isDanger: Boolean = false, - onClick: () -> Unit, -) { - SettingsItemRow( - title = title, - isDanger = isDanger, - onClick = onClick, - leadingIcon = { tint -> - Icon( - symbol = icon, - contentDescription = stringRes(title), - modifier = Modifier.size(20.dp), + 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) }, ) - }, - ) -} - -@Composable -private fun SettingsItem( - title: Int, - iconPainter: Int, - iconPainterRef: Int, - 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), + 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) }, + ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.request_to_vanish, + icon = Icons.Outlined.DeleteForever, + tint = tint, + onClick = { nav.nav(Route.RequestToVanish) }, + ) + HorizontalDivider() + } + SettingsNavigationRow( + title = R.string.vanish_history, + icon = Icons.Outlined.History, + tint = tint, + onClick = { nav.nav(Route.VanishEvents) }, + ) + } + } +} + +@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), ) } @Composable -private fun SettingsItemRow( +private fun SettingsNavigationRow( title: Int, - isDanger: Boolean, + icon: ImageVector, + tint: Color, 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(horizontal = 16.dp, vertical = 12.dp), + .padding(vertical = 16.dp, horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = - Modifier - .size(36.dp) - .clip(RoundedCornerShape(10.dp)) - .background(containerColor), - contentAlignment = Alignment.Center, - ) { - leadingIcon(iconTint) - } + Icon( + imageVector = icon, + contentDescription = stringRes(title), + modifier = Modifier.size(24.dp), + tint = tint, + ) Text( text = stringRes(title), - 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, + fontSize = 18.sp, + modifier = Modifier.padding(start = 16.dp), + ) + } +} + +@Composable +private fun SettingsNavigationRow( + title: Int, + iconPainter: Int, + iconPainterRef: Int, + tint: Color, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 16.dp, horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterRes(iconPainter, iconPainterRef), + contentDescription = stringRes(title), + modifier = Modifier.size(24.dp), + tint = tint, + ) + Text( + text = stringRes(title), + fontSize = 18.sp, + modifier = Modifier.padding(start = 16.dp), ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/UserSearchEngine.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/UserSearchEngine.kt new file mode 100644 index 000000000..d094da60a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/UserSearchEngine.kt @@ -0,0 +1,149 @@ +/* + * 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.commons.search + +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Delegate for searching users via relays (NIP-50 or other mechanism). + * Platform-specific: desktop uses relay manager, Android could use its own. + */ +interface RelayUserSearchDelegate { + fun searchPeople( + query: String, + limit: Int, + onResult: (User) -> Unit, + onComplete: () -> Unit, + ): Job +} + +/** + * Reusable user search engine that combines local cache search with optional relay search. + * Platform-agnostic — lives in commons, relay interaction via delegate. + * + * Usage: + * ``` + * val engine = UserSearchEngine(cache, scope) + * engine.relayDelegate = myRelayDelegate // optional + * engine.search("fiatjaf") + * // collect engine.results, engine.isSearching + * ``` + */ +class UserSearchEngine( + private val cache: ICacheProvider, + private val scope: CoroutineScope, + private val debounceMs: Long = 300L, + private val localLimit: Int = 10, + private val relayLimit: Int = 10, +) { + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + private val _localResults = MutableStateFlow>(emptyList()) + val localResults: StateFlow> = _localResults.asStateFlow() + + private val _relayResults = MutableStateFlow>(emptyList()) + val relayResults: StateFlow> = _relayResults.asStateFlow() + + private val _isSearching = MutableStateFlow(false) + val isSearching: StateFlow = _isSearching.asStateFlow() + + var relayDelegate: RelayUserSearchDelegate? = null + + private var relaySearchJob: Job? = null + + init { + setupDebouncedSearch() + } + + fun search(text: String) { + _query.value = text + _relayResults.value = emptyList() + relaySearchJob?.cancel() + + if (text.length < 2) { + _localResults.value = emptyList() + _isSearching.value = false + } + } + + fun clear() = search("") + + /** + * Resolves an input string to a hex pubkey. + * Handles npub, nprofile, and raw hex. + */ + fun resolveToHex(input: String): String = decodePublicKeyAsHexOrNull(input.trim()) ?: input.trim() + + @OptIn(FlowPreview::class) + private fun setupDebouncedSearch() { + _query + .debounce(debounceMs) + .onEach { text -> + if (text.length >= 2) { + // Local cache search (instant) + _localResults.value = cache.findUsersStartingWith(text, localLimit) + + // Relay search (async, via delegate) + startRelaySearch(text) + } else { + _localResults.value = emptyList() + _isSearching.value = false + } + }.launchIn(scope) + } + + private fun startRelaySearch(text: String) { + val delegate = relayDelegate ?: return + relaySearchJob?.cancel() + _isSearching.value = true + _relayResults.value = emptyList() + + relaySearchJob = + delegate.searchPeople( + query = text, + limit = relayLimit, + onResult = { user -> + // Deduplicate against local results and existing relay results + val isDuplicate = + _localResults.value.any { it.pubkeyHex == user.pubkeyHex } || + _relayResults.value.any { it.pubkeyHex == user.pubkeyHex } + if (!isDuplicate) { + _relayResults.value = _relayResults.value + user + } + }, + onComplete = { + _isSearching.value = false + }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index eab68c33d..2de05e998 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -876,158 +876,163 @@ fun App( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, ) { - when (accountState) { - is AccountState.LoggedOut -> { - LoginScreen( - accountManager = accountManager, - onLoginSuccess = { - // Start heartbeat if bunker account - val current = accountManager.currentAccount() - if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) { - accountManager.startHeartbeat(scope) - } - // Ensure account is in multi-account storage + refresh list - scope.launch(Dispatchers.IO) { - accountManager.ensureCurrentAccountInStorage() - accountManager.refreshAccountList() - } - }, - ) - } - - is AccountState.ConnectingRelays -> { - val relays by relayManager.relayStatuses.collectAsState() - ConnectingRelaysScreen( - subtitle = "Restoring remote signer session", - relayStatuses = relays, - ) - } - - is AccountState.LoggedIn -> { - val account = accountState as AccountState.LoggedIn - val nwcConnection by accountManager.nwcConnection.collectAsState() - - // Load NWC connection on first composition - LaunchedEffect(Unit) { - accountManager.loadNwcConnection() - } - - val currentTorStatus = torManager.status.collectAsState().value - androidx.compose.runtime.CompositionLocalProvider( - com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides - com.vitorpamplona.amethyst.desktop.ui.tor.TorState( - status = currentTorStatus, - settings = torSettings, - onSettingsChanged = { newSettings -> - torSettings = newSettings - com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences - .save(newSettings) - torTypeFlow.value = newSettings.torType - externalPortFlow.value = newSettings.externalSocksPort - // Rebuild app to apply Tor changes - onRestartApp() - }, - ), - ) { - MainContent( - layoutMode = layoutMode, - deckState = deckState, - workspaceManager = workspaceManager, - singlePaneState = singlePaneState, - pinnedNavBarState = pinnedNavBarState, - relayManager = relayManager, - localCache = localCache, + CompositionLocalProvider( + com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, + com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, + ) { + when (accountState) { + is AccountState.LoggedOut -> { + LoginScreen( accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - nip11Fetcher = nip11Fetcher, - appScope = scope, - torStatus = currentTorStatus, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onShowAppDrawer = onShowAppDrawer, - onOpenFeedsDrawer = { - appDrawerInitialTab = - com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS - onShowAppDrawer() - }, - ) - } - - // Compose dialog - if (showComposeDialog) { - ComposeNoteDialog( - onDismiss = onDismissComposeDialog, - relayManager = relayManager, - account = account, - replyTo = replyToNote, - ) - } - - // App Drawer overlay - if (showAppDrawer) { - val openColumns by deckState.columns.collectAsState() - AppDrawer( - initialTab = appDrawerInitialTab, - openColumnTypes = - if (layoutMode == LayoutMode.DECK) { - openColumns.map { it.type.typeKey() }.toSet() - } else { - emptySet() - }, - pinnedNavBarState = pinnedNavBarState, - workspaceManager = workspaceManager, - onSwitchWorkspace = { ws -> - // Switch layout mode to match workspace - onLayoutModeChange(ws.layoutMode) - // Load columns or single pane screen - when (ws.layoutMode) { - LayoutMode.DECK -> { - deckState.loadFromWorkspace(ws.columns) - } - - LayoutMode.SINGLE_PANE -> { - // Load nav bar from workspace + navigate to first screen - pinnedNavBarState.loadFromWorkspace() - val firstKey = - ws.singlePaneScreens.firstOrNull() ?: "home" - val type = DeckState.parseColumnTypeFromKey(firstKey) - if (type != null) singlePaneState.navigate(type) - } + onLoginSuccess = { + // Start heartbeat if bunker account + val current = accountManager.currentAccount() + if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) { + accountManager.startHeartbeat(scope) + } + // Ensure account is in multi-account storage + refresh list + scope.launch(Dispatchers.IO) { + accountManager.ensureCurrentAccountInStorage() + accountManager.refreshAccountList() } }, - onSelectScreen = { type -> - when (layoutMode) { - LayoutMode.DECK -> { - if (deckState.hasColumnOfType(type)) { - deckState.focusExistingColumn(type) - } else { - deckState.addColumn(type) + ) + } + + is AccountState.ConnectingRelays -> { + val relays by relayManager.relayStatuses.collectAsState() + ConnectingRelaysScreen( + subtitle = "Restoring remote signer session", + relayStatuses = relays, + ) + } + + is AccountState.LoggedIn -> { + val account = accountState as AccountState.LoggedIn + val nwcConnection by accountManager.nwcConnection.collectAsState() + + // Load NWC connection on first composition + LaunchedEffect(Unit) { + accountManager.loadNwcConnection() + } + + val currentTorStatus = torManager.status.collectAsState().value + androidx.compose.runtime.CompositionLocalProvider( + com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides + com.vitorpamplona.amethyst.desktop.ui.tor.TorState( + status = currentTorStatus, + settings = torSettings, + onSettingsChanged = { newSettings -> + torSettings = newSettings + com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences + .save(newSettings) + torTypeFlow.value = newSettings.torType + externalPortFlow.value = newSettings.externalSocksPort + // Rebuild app to apply Tor changes + onRestartApp() + }, + ), + ) { + MainContent( + layoutMode = layoutMode, + deckState = deckState, + workspaceManager = workspaceManager, + singlePaneState = singlePaneState, + pinnedNavBarState = pinnedNavBarState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + nip11Fetcher = nip11Fetcher, + appScope = scope, + torStatus = currentTorStatus, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onShowAppDrawer = onShowAppDrawer, + onOpenFeedsDrawer = { + appDrawerInitialTab = + com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS + onShowAppDrawer() + }, + ) + } + + // Compose dialog + if (showComposeDialog) { + ComposeNoteDialog( + onDismiss = onDismissComposeDialog, + relayManager = relayManager, + account = account, + replyTo = replyToNote, + ) + } + + // App Drawer overlay + if (showAppDrawer) { + val openColumns by deckState.columns.collectAsState() + AppDrawer( + initialTab = appDrawerInitialTab, + openColumnTypes = + if (layoutMode == LayoutMode.DECK) { + openColumns.map { it.type.typeKey() }.toSet() + } else { + emptySet() + }, + pinnedNavBarState = pinnedNavBarState, + workspaceManager = workspaceManager, + onSwitchWorkspace = { ws -> + // Switch layout mode to match workspace + onLayoutModeChange(ws.layoutMode) + // Load columns or single pane screen + when (ws.layoutMode) { + LayoutMode.DECK -> { + deckState.loadFromWorkspace(ws.columns) + } + + LayoutMode.SINGLE_PANE -> { + // Load nav bar from workspace + navigate to first screen + pinnedNavBarState.loadFromWorkspace() + val firstKey = + ws.singlePaneScreens.firstOrNull() ?: "home" + val type = DeckState.parseColumnTypeFromKey(firstKey) + if (type != null) singlePaneState.navigate(type) } } + }, + onSelectScreen = { type -> + when (layoutMode) { + LayoutMode.DECK -> { + if (deckState.hasColumnOfType(type)) { + deckState.focusExistingColumn(type) + } else { + deckState.addColumn(type) + } + } - LayoutMode.SINGLE_PANE -> { - singlePaneState.navigate(type) + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(type) + } } - } - }, - onDismiss = { - appDrawerInitialTab = null - onDismissAppDrawer() - }, - ) + }, + onDismiss = { + appDrawerInitialTab = null + onDismissAppDrawer() + }, + ) + } } } - } - // Force logout dialog overlay - val forceLogoutReason by accountManager.forceLogoutReason.collectAsState() - forceLogoutReason?.let { reason -> - ForceLogoutDialog( - reason = reason, - onDismiss = { accountManager.clearForceLogoutReason() }, - ) + // Force logout dialog overlay + val forceLogoutReason by accountManager.forceLogoutReason.collectAsState() + forceLogoutReason?.let { reason -> + ForceLogoutDialog( + reason = reason, + onDismiss = { accountManager.clearForceLogoutReason() }, + ) + } } } } @@ -1245,6 +1250,7 @@ fun MainContent( LocalRelayCategories provides relayCategories, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, + com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, ) { Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/search/DesktopRelayUserSearchDelegate.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/search/DesktopRelayUserSearchDelegate.kt new file mode 100644 index 000000000..a58d2bad7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/search/DesktopRelayUserSearchDelegate.kt @@ -0,0 +1,101 @@ +/* + * 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.search + +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.search.RelayUserSearchDelegate +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class DesktopRelayUserSearchDelegate( + private val relayManager: RelayConnectionManager, + private val localCache: DesktopLocalCache, + private val searchRelays: () -> Set, + private val scope: CoroutineScope, +) : RelayUserSearchDelegate { + private var currentSubId: String? = null + + override fun searchPeople( + query: String, + limit: Int, + onResult: (User) -> Unit, + onComplete: () -> Unit, + ): Job = + scope.launch { + // Cancel previous subscription + currentSubId?.let { relayManager.unsubscribe(it) } + + val relays = searchRelays() + if (relays.isEmpty()) { + onComplete() + return@launch + } + + val subId = generateSubId("author-search") + currentSubId = subId + + relayManager.subscribe( + subId = subId, + filters = listOf(FilterBuilders.searchPeople(query, limit)), + relays = relays, + listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + val user = localCache.getUserIfExists(event.pubKey) + if (user != null) { + onResult(user) + } + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onComplete() + } + }, + ) + + // Timeout after 8 seconds + delay(8000) + relayManager.unsubscribe(subId) + onComplete() + } +} 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 815c09234..1cd53ccbb 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 @@ -67,6 +67,7 @@ fun FeedsDrawerTab( onSelectFeed: (FeedDefinition) -> Unit, onDismiss: () -> Unit, localCache: DesktopLocalCache? = LocalDesktopCache.current, + relayManager: com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager? = LocalRelayManager.current, feedRepository: FeedDefinitionRepository = LocalFeedRepository.current, scope: CoroutineScope = LocalFeedScope.current, ) { @@ -79,6 +80,7 @@ fun FeedsDrawerTab( if (showBuilder) { FeedBuilderDialog( localCache = localCache, + relayManager = relayManager, onSave = { feed -> scope.launch { feedRepository.add(feed) } showBuilder = false diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt index 13ea7d65e..651db0f91 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinitionRepository import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinitionSerializer import com.vitorpamplona.amethyst.commons.feeds.custom.defaultFeeds import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.launchIn @@ -74,3 +75,8 @@ val LocalDesktopCache = compositionLocalOf { null } + +val LocalRelayManager = + compositionLocalOf { + null + }