From b472be3a1add6798d577d6a07aa6ca4a962fb326 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 16:39:14 +0000 Subject: [PATCH] feat: add relay search suggestions to all Add a Relay fields When the user types in any "Add a Relay" field in AllRelayListScreen, the app now searches HintIndexer's relayDB for matching relay URLs and displays them as clickable suggestions below the text field. Clicking a suggestion fills the field with the relay URL, matching the design pattern of ShowUserSuggestionList in ShortNotePostScreen. New files: - RelaySuggestionState: debounced Flow-based state filtering relayDB - ShowRelaySuggestionList: Column of clickable RelayUrlLine rows https://claude.ai/code/session_0115sYZGVQBZLX7s9oCDcKoi --- .../relays/common/RelaySuggestionState.kt | 62 +++++++++ .../relays/common/RelayUrlEditField.kt | 124 ++++++++++-------- .../relays/common/ShowRelaySuggestionList.kt | 78 +++++++++++ 3 files changed, 207 insertions(+), 57 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt new file mode 100644 index 000000000..cd772c2f9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt @@ -0,0 +1,62 @@ +/* + * 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.ui.screen.loggedIn.relays.common + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +@Stable +class RelaySuggestionState { + val currentInput = MutableStateFlow("") + + @OptIn(FlowPreview::class) + val results = + currentInput + .debounce(300) + .distinctUntilChanged() + .map { input -> + if (input.length > 1) { + val lower = input.lowercase() + LocalCache.relayHints.relayDB + .filter { _, relay -> relay.url.contains(lower) } + .sortedBy { it.url } + .take(20) + } else { + emptyList() + } + }.flowOn(Dispatchers.IO) + + fun processInput(input: String) { + currentInput.tryEmit(input) + } + + fun reset() { + currentInput.tryEmit("") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index cb56ce80c..cdbf1a048 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -61,64 +62,73 @@ fun RelayUrlEditFieldPreview() { @Composable fun RelayUrlEditField(onNewRelay: (NormalizedRelayUrl) -> Unit) { var url by remember { mutableStateOf("") } + val relaySuggestions = remember { RelaySuggestionState() } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Size10dp)) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.add_a_relay)) }, - modifier = Modifier.weight(1f), - value = url, - onValueChange = { url = it }, - placeholder = { - Text( - text = "server.com", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - }, - singleLine = true, - keyboardOptions = - KeyboardOptions.Default.copy( - autoCorrectEnabled = false, - imeAction = ImeAction.Go, - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Text, - ), - keyboardActions = - KeyboardActions( - onGo = { - if (url.isNotBlank() && url != "/") { - val relay = RelayUrlNormalizer.normalizeOrNull(url) - if (relay != null) { - onNewRelay(relay) - url = "" - } - } - }, - ), - ) - - Button( - onClick = { - if (url.isNotBlank() && url != "/") { - val relay = RelayUrlNormalizer.normalizeOrNull(url) - if (relay != null) { - onNewRelay(relay) - url = "" - } - } - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = - if (url.isNotBlank()) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.placeholderText - }, - ), - ) { - Text(text = stringRes(id = R.string.add), color = Color.White) + fun submitRelay() { + if (url.isNotBlank() && url != "/") { + val relay = RelayUrlNormalizer.normalizeOrNull(url) + if (relay != null) { + onNewRelay(relay) + url = "" + relaySuggestions.reset() + } } } + + Column { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Size10dp)) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.add_a_relay)) }, + modifier = Modifier.weight(1f), + value = url, + onValueChange = { + url = it + relaySuggestions.processInput(it) + }, + placeholder = { + Text( + text = "server.com", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + }, + singleLine = true, + keyboardOptions = + KeyboardOptions.Default.copy( + autoCorrectEnabled = false, + imeAction = ImeAction.Go, + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + ), + keyboardActions = + KeyboardActions( + onGo = { submitRelay() }, + ), + ) + + Button( + onClick = { submitRelay() }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = + if (url.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.placeholderText + }, + ), + ) { + Text(text = stringRes(id = R.string.add), color = Color.White) + } + } + + ShowRelaySuggestionList( + relaySuggestions = relaySuggestions, + onSelect = { relay -> + url = relay.url + relaySuggestions.reset() + }, + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt new file mode 100644 index 000000000..8ce412ce6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt @@ -0,0 +1,78 @@ +/* + * 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.ui.screen.loggedIn.relays.common + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl + +@Composable +fun ShowRelaySuggestionList( + relaySuggestions: RelaySuggestionState, + onSelect: (NormalizedRelayUrl) -> Unit, + modifier: Modifier = Modifier, +) { + val suggestions by relaySuggestions.results.collectAsStateWithLifecycle(emptyList()) + + if (suggestions.isNotEmpty()) { + Column(modifier = modifier) { + suggestions.forEachIndexed { index, relay -> + RelayUrlLine(relay) { onSelect(relay) } + if (index < suggestions.lastIndex) { + HorizontalDivider(thickness = DividerThickness) + } + } + } + } +} + +@Composable +fun RelayUrlLine( + relay: NormalizedRelayUrl, + onClick: () -> Unit, +) { + Text( + text = relay.displayUrl(), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) +}