android, desktop: online search model, result rows, first-search alert

Port OnlineSearchModel and its rows from the iOS DirectorySearchView:
directory pages and SimpleX name resolution share one lifecycle, run on
the main thread like the iOS @MainActor model, and drop late replies by
generation. Add the "Search in Directory" row, the directory and name
rows, the first-search alert, and an onSearch handler on SearchTextField
(Android IME Search action, desktop Enter). Nothing calls them yet.

Run: 003-kotlin-directory-search, slice 3
This commit is contained in:
spacedandy-agents-simplex committed 2026-10-14 13:34:40 +00:00
1 parent 2137884bde
commit 1980f96746
3 files changed
+386

No files matched your search

@@ -1,3 +1,363 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.subscriberCountStr
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.serialization.json.JsonObject
enum class DirectoryFooter { Retry, ShowMore }
// Online results are not chats and are never saved: they live as long as the search text does.
class OnlineSearchModel {
var names by mutableStateOf<List<NameSearchResult>>(emptyList())
private set
var directory by mutableStateOf<List<DirectoryResult>>(emptyList())
private set
private val _loading = mutableStateOf(false)
var loading: Boolean
get() = _loading.value
private set(value) { _loading.value = value; updateSpinner() }
private val _resolvingNames = mutableStateOf(false)
var resolvingNames: Boolean
get() = _resolvingNames.value
private set(value) { _resolvingNames.value = value; updateSpinner() }
var failed by mutableStateOf(false)
private set
// tells "nothing searched yet" apart from "searched and found nothing"
var searched by mutableStateOf(false)
private set
private var cursor by mutableStateOf<JsonObject?>(null)
private var searchedText = ""
// bumped on every reset, so a reply that arrives too late cannot refill a list already cleared
private var generation = 0
private var spinnerShown = false
val searching: Boolean get() = loading || resolvingNames
val showResults: Boolean get() = searching || searched || names.isNotEmpty()
val footer: DirectoryFooter? get() = if (failed) DirectoryFooter.Retry else if (cursor != null) DirectoryFooter.ShowMore else null
fun reset() {
generation += 1
names = emptyList()
directory = emptyList()
cursor = null
loading = false
resolvingNames = false
failed = false
searched = false
searchedText = ""
}
// a way out of the wait, not out of the results: what is already shown stays
fun cancelRequest() {
generation += 1
loading = false
resolvingNames = false
}
suspend fun search(text: String, name: String?) {
val text = text.trim()
if (text.isEmpty() || (searching && text == searchedText)) return
reset()
searchedText = text
if (name != null) {
resolvingNames = true
// captured here: the async block runs later, in the child coroutine, possibly after a reset
val gen = generation
coroutineScope {
val resolution = async { resolveNames(name, gen) }
request(append = false)
resolution.await()
}
} else {
request(append = false)
}
}
suspend fun searchDirectory(text: String) {
val text = text.trim()
if (text.isEmpty() || (loading && text == searchedText)) return
if (text == searchedText) {
directory = emptyList()
cursor = null
failed = false
searched = false
} else {
reset()
searchedText = text
}
request(append = false)
}
suspend fun loadMore() {
if (cursor == null || loading) return
request(append = true)
}
// a failed "Show more" keeps its cursor, so the retry resumes the page rather than starting over
suspend fun retry() {
if (cursor != null) {
loadMore()
} else if (!loading) {
request(append = false)
}
}
fun rows(shownChatIds: Set<ChatId>): OnlineSearchRows {
val nameChatIds = names.mapNotNull { it.localChatId }.toSet()
val nameLinks = names.mapNotNull { it.resolvedLink }.toSet()
fun isAmong(ids: Set<ChatId>, id: ChatId?): Boolean =
id?.let { ids.contains(it) } ?: false
return OnlineSearchRows(
names = names.filter { !isAmong(shownChatIds, it.localChatId) },
directory = directory.filter {
!isAmong(shownChatIds, it.localChatId) && !isAmong(nameChatIds, it.localChatId) && !nameLinks.contains(it.entry.link)
},
footer = footer
)
}
private suspend fun resolveNames(name: String, gen: Int) {
val rh = chatModel.remoteHostId()
val targets = if (name.startsWith("@") || name.startsWith("#")) listOf(name) else listOf("@$name", "#$name")
val results = mutableListOf<NameSearchResult>()
for (target in targets) {
// checked before each target: a cancelled or replaced search must not send more names to the server
if (gen != generation) return
val r = chatModel.controller.apiConnectPlan(rh, target, resolveMode = PlanResolveMode.PRMUnknown, inProgress = mutableStateOf(false), onFailure = ConnectPlanFailure.Log)
val result = if (r != null) NameSearchResult(target, r) else null
if (result != null) {
results.add(result)
}
}
if (gen != generation) return
resolvingNames = false
names = results
}
private suspend fun request(append: Boolean) {
val rh = chatModel.remoteHostId()
val gen = generation
loading = true
failed = false
val r = chatModel.controller.apiSearchDirectory(rh, searchedText, cursor)
val page = mutableListOf<DirectoryResult>()
if (r != null) {
val seen = directory.map { it.id }.toMutableSet()
for (entry in r.entries) {
if (!seen.add(entry.id)) continue
val plan = chatModel.controller.apiConnectPlan(rh, entry.link, resolveMode = PlanResolveMode.PRMNever, inProgress = mutableStateOf(false), onFailure = ConnectPlanFailure.Log)
page.add(DirectoryResult(entry = entry, localChatId = plan?.let { planLocalChatId(it.connectionPlan) }))
}
}
if (gen != generation) return
loading = false
searched = true
if (r == null) {
failed = true
return
}
cursor = r.cursor
directory = if (append) directory + page else page
}
private fun updateSpinner() {
if (searching == spinnerShown) return
spinnerShown = searching
if (spinnerShown) {
ConnectProgressManager.startConnectProgress(
generalGetString(MR.strings.searching_directory),
owner = ConnectProgressOwner.DirectorySearch
) {
withApi { cancelRequest() }
}
} else {
ConnectProgressManager.stopConnectProgress(ConnectProgressOwner.DirectorySearch)
}
}
}
@Composable
fun SearchInDirectoryRow(onSearch: () -> Unit) {
val view = LocalMultiplatformView()
Row(
Modifier
.fillMaxWidth()
.clickable {
hideKeyboard(view)
onSearch()
}
.padding(vertical = DEFAULT_PADDING_HALF),
verticalAlignment = Alignment.CenterVertically
) {
Icon(painterResource(MR.images.ic_search), null, Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.primary)
Text(stringResource(MR.strings.search_in_directory), color = MaterialTheme.colors.primary)
}
}
@Composable
fun DirectorySearchRow(entry: DirectorySearchEntry, modifier: Modifier = Modifier) {
val displayName = "#" + entry.displayName
val isChannel = entry.entryType.groupType == GroupType.Channel
val summary = entry.entryType.summary
val count = summary.publicMemberCount ?: summary.currentMembers
val membersText = if (isChannel) subscriberCountStr(count) else String.format(generalGetString(MR.strings.directory_search_n_members), count)
Row(
modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ProfileImage(
size = 42.dp,
image = entry.image,
icon = if (isChannel) MR.images.ic_bigtop_updates_circle_filled else MR.images.ic_supervised_user_circle_filled
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(displayName, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
val simplexName = entry.simplexName
if (simplexName != null) {
// the directory's claim, shown as plain text: connecting uses the link
Text(
simplexName,
Modifier.weight(1f, fill = false),
color = MaterialTheme.colors.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
val descr = entry.shortDescr
if (descr != null && descr.isNotEmpty()) {
Text(descr, color = MaterialTheme.colors.secondary, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
Text(membersText, style = MaterialTheme.typography.caption, color = MaterialTheme.colors.secondary)
}
Text(
stringResource(if (isChannel) MR.strings.compose_view_join_channel else MR.strings.compose_view_join_group),
color = MaterialTheme.colors.primary,
maxLines = 1,
softWrap = false
)
}
}
@Composable
fun NameSearchRow(result: NameSearchResult, modifier: Modifier = Modifier) {
Row(
modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ProfileImage(size = 42.dp, image = result.image, icon = result.iconName)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
val profileName = result.profileName
if (profileName != null) {
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(profileName) }
withStyle(SpanStyle(color = MaterialTheme.colors.secondary)) { append(" (${result.simplexName})") }
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
} else {
Text(result.simplexName, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
val descr = result.descr
if (descr != null && descr.isNotEmpty()) {
Text(descr, color = MaterialTheme.colors.secondary, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
}
@Composable
fun DirectoryShowMoreRow(onlineSearch: OnlineSearchModel) {
Row(
Modifier
.fillMaxWidth()
.clickable(enabled = !onlineSearch.loading) {
withApi { onlineSearch.loadMore() }
}
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)
) {
Text(
stringResource(MR.strings.directory_search_show_more),
color = if (onlineSearch.loading) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
}
@Composable
fun DirectoryRetryRow(onlineSearch: OnlineSearchModel) {
Row(
Modifier
.fillMaxWidth()
.clickable {
withApi { onlineSearch.retry() }
}
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)
) {
Text(stringResource(MR.strings.directory_search_failed_retry), color = MaterialTheme.colors.primary)
}
}
fun confirmDirectorySearch(search: () -> Unit) {
if (appPrefs.directorySearchAlertShown.get()) {
search()
} else {
showDirectorySearchAlert(onSearch = search)
}
}
private fun showDirectorySearchAlert(onSearch: () -> Unit) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.search_in_directory_question),
text = generalGetString(MR.strings.search_in_directory_alert_desc),
parseHtml = false,
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
onSearch()
}) {
Text(stringResource(MR.strings.search_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
appPrefs.directorySearchAlertShown.set(true)
onSearch()
}) {
Text(stringResource(MR.strings.search_and_dont_show_again), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.*
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import dev.icerock.moko.resources.compose.painterResource
@@ -37,6 +38,7 @@ fun SearchTextField(
enabled: Boolean = true,
trailingContent: @Composable (() -> Unit)? = null,
reducedCloseButtonPadding: Dp = 8.dp,
onSearch: (() -> Unit)? = null,
onValueChange: (String) -> Unit
) {
val focusRequester = remember { FocusRequester() }
@@ -90,6 +92,14 @@ fun SearchTextField(
.background(colors.backgroundColor(enabled).value, shape)
.indicatorLine(enabled, false, interactionSource, colors)
.focusRequester(focusRequester)
.onPreviewKeyEvent {
if (onSearch != null && appPlatform.isDesktop && (it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyUp) {
onSearch()
true
} else {
false
}
}
.padding(padding)
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
@@ -103,6 +113,12 @@ fun SearchTextField(
cursorBrush = SolidColor(colors.cursorColor(false).value),
visualTransformation = VisualTransformation.None,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = if (onSearch != null && appPlatform.isAndroid) {
{
keyboard?.hide()
onSearch()
}
} else null),
singleLine = true,
textStyle = textStyle,
interactionSource = interactionSource,
@@ -513,6 +513,16 @@
<string name="contact_should_accept">contact should accept…</string>
<string name="connect_with_contact_name_question">Connect with %1$s?</string>
<string name="search_or_paste_simplex_link">Search or paste SimpleX link</string>
<string name="search_in_directory">Search in Directory</string>
<string name="search_in_directory_question">Search in Directory?</string>
<string name="search_in_directory_alert_desc">The text in the search field will be sent to SimpleX Directory to find public groups and channels. If it is a SimpleX name, it will also be sent to your server that resolves names.\n\nNo contact is created and your profile is not sent.</string>
<string name="search_and_dont_show_again">Search and don\'t show again</string>
<string name="directory_search_n_members">%1$d members</string>
<string name="directory_search_show_more">Show more</string>
<string name="directory_search_failed_retry">Search failed, tap to retry</string>
<string name="searching_directory">Searching directory…</string>
<string name="online_search_names">Names</string>
<string name="online_search_directory">Directory</string>
<!-- ChatView.kt -->
<string name="no_selected_chat">No selected chat</string>