mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 10:32:27 +00:00
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:
1 parent
2137884bde
commit
1980f96746
3 files changed
+386
No files matched your search
+360
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+16
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user