android, desktop: search names and the directory from the chat list

Port the iOS chat list online search: the Search key resolves a typed
SimpleX name online and searches the directory, the "Search in
Directory" row searches the directory only, and the Names and Directory
parts follow the local chats, de-duplicated against them. The onboarding
state now shows the search bar too, with its results in place of the
onboarding pages (on desktop the pages stay in the centre pane). The
empty-state overlays no longer cover online results.

Run: 003-kotlin-directory-search, slice 4
This commit is contained in:
spacedandy-agents-simplex
2026-10-14 13:40:52 +00:00
parent 1980f96746
commit 15f2326704
@@ -17,6 +17,8 @@ import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.platform.*
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -241,9 +243,10 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<Set<String>>(emptySet()) }
val connectNameCandidate = remember { mutableStateOf<String?>(null) }
val onlineSearch = remember { OnlineSearchModel() }
Box(Modifier.fillMaxSize()) {
if (oneHandUI.value) {
ChatListWithLoadingScreen(searchText, listState, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate)
ChatListWithLoadingScreen(searchText, listState, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate, onlineSearch)
Column(Modifier.align(Alignment.BottomCenter)) {
ChatListToolbar(
userPickerState,
@@ -253,7 +256,7 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
)
}
} else {
ChatListWithLoadingScreen(searchText, listState, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate)
ChatListWithLoadingScreen(searchText, listState, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate, onlineSearch)
Column {
ChatListToolbar(
userPickerState,
@@ -449,33 +452,105 @@ private fun ConnectBannerCard() {
}
@Composable
private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState<TextFieldValue>, listState: LazyListState, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<Set<String>>, connectNameCandidate: MutableState<String?>) {
private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState<TextFieldValue>, listState: LazyListState, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<Set<String>>, connectNameCandidate: MutableState<String?>, onlineSearch: OnlineSearchModel) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
if (chatModel.chatRunning.value == null) {
Text(stringResource(MR.strings.loading_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
} else if (shouldShowOnboarding()) {
if (appPlatform.isAndroid) AndroidOnboardingCards()
// the search bar is shown here too: someone with no chats yet is who most needs to find some
val topPad = topPaddingToContent(false)
val bottomPad = if (oneHandUI.value) {
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier
} else {
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
}
Column(Modifier.fillMaxSize().padding(top = topPad, bottom = bottomPad).consumeWindowInsets(WindowInsets.navigationBars)) {
if (!oneHandUI.value) {
ChatListSearchBarWithTags(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate, onlineSearch)
}
Box(Modifier.weight(1f).fillMaxWidth()) {
if (onlineSearch.showResults) {
val rows = onlineSearch.rows(shownChatIds = emptySet())
LazyColumnWithScrollBarNoAppBar(
if (!oneHandUI.value) Modifier.fillMaxSize().imePadding() else Modifier.fillMaxSize(),
reverseLayout = oneHandUI.value
) {
nameRows(rows.names)
directoryRows(rows, onlineSearch)
if (!onlineSearch.searching && rows.isEmpty) {
item {
Text(
stringResource(MR.strings.no_chats_found),
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
color = MaterialTheme.colors.secondary,
textAlign = TextAlign.Center
)
}
}
}
} else if (appPlatform.isAndroid) {
ConnectOnboardingView()
}
}
if (oneHandUI.value) {
ChatListSearchBarWithTags(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate, onlineSearch)
}
}
} else {
if (!chatModel.desktopNoUserNoRemote) {
ChatList(searchText = searchText, listState, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate)
ChatList(searchText = searchText, listState, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate, onlineSearch)
}
if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote && !onlineSearch.showResults) {
Text(stringResource(MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
}
}
}
private fun LazyListScope.nameRows(names: List<NameSearchResult>) {
if (names.isNotEmpty()) {
item { SearchPartHeader(stringResource(MR.strings.online_search_names)) }
items(names, key = { it.id }) { result ->
val view = LocalMultiplatformView()
NameSearchRow(
result,
Modifier.clickable {
hideKeyboard(view)
withBGApi { planAndConnect(chatModel.remoteHostId(), result.target, close = null, cleanup = null) }
}
)
}
}
}
private fun LazyListScope.directoryRows(rows: OnlineSearchRows, onlineSearch: OnlineSearchModel) {
if (rows.directory.isNotEmpty() || rows.footer != null) {
item { SearchPartHeader(stringResource(MR.strings.online_search_directory)) }
items(rows.directory, key = { it.id }) { result ->
val view = LocalMultiplatformView()
DirectorySearchRow(
result.entry,
Modifier.clickable {
hideKeyboard(view)
withBGApi { planAndConnect(chatModel.remoteHostId(), result.entry.link, close = null, cleanup = null) }
}
)
}
when (rows.footer) {
DirectoryFooter.Retry -> item { DirectoryRetryRow(onlineSearch) }
DirectoryFooter.ShowMore -> item { DirectoryShowMoreRow(onlineSearch) }
null -> {}
}
}
}
@Composable
private fun AndroidOnboardingCards() {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val topPad = topPaddingToContent(false)
val bottomPad = if (oneHandUI.value) {
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier
} else {
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
}
Box(Modifier.fillMaxSize().padding(top = topPad, bottom = bottomPad)) {
ConnectOnboardingView()
}
private fun SearchPartHeader(title: String) {
Text(
title,
Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).semantics { heading() },
color = MaterialTheme.colors.secondary,
style = MaterialTheme.typography.body2
)
}
@Composable
@@ -788,7 +863,7 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
}
@Composable
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<Set<String>>, connectNameCandidate: MutableState<String?>) {
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<Set<String>>, connectNameCandidate: MutableState<String?>, onlineSearch: OnlineSearchModel) {
Box {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val focusRequester = remember { FocusRequester() }
@@ -808,6 +883,7 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
trailingContent = null,
// the clear button must line up with the filter icon it replaces, so no reduction here
reducedCloseButtonPadding = 0.dp,
onSearch = { runOnlineSearch(searchText, searchShowingSimplexLink, connectNameCandidate, onlineSearch) },
) {
searchText.value = searchText.value.copy(it)
}
@@ -832,6 +908,12 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
focusManager.clearFocus()
}
}
KeyChangeEffect(searchText.value.text) {
onlineSearch.reset()
}
KeyChangeEffect(chatModel.currentUser.value?.userId) {
onlineSearch.reset()
}
val view = LocalMultiplatformView()
LaunchedEffect(Unit) {
snapshotFlow { searchText.value.text }
@@ -900,6 +982,23 @@ private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<
}
}
private fun searchTrimmed(searchText: State<TextFieldValue>): String =
searchText.value.text.trim()
// whatever is typed here gets sent to the directory, so ask before the first time
private fun runDirectorySearch(searchText: State<TextFieldValue>, searchShowingSimplexLink: State<Boolean>, onlineSearch: OnlineSearchModel) {
val text = searchTrimmed(searchText)
if (text.isEmpty() || searchShowingSimplexLink.value) return
confirmDirectorySearch { withApi { onlineSearch.searchDirectory(text) } }
}
private fun runOnlineSearch(searchText: State<TextFieldValue>, searchShowingSimplexLink: State<Boolean>, connectNameCandidate: State<String?>, onlineSearch: OnlineSearchModel) {
val text = searchTrimmed(searchText)
if (text.isEmpty() || searchShowingSimplexLink.value) return
val name = connectNameCandidate.value
confirmDirectorySearch { withApi { onlineSearch.search(text, name) } }
}
@Composable
private fun ErrorSettingsView() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -944,7 +1043,7 @@ fun BoxScope.NavigationBarBackground(modifier: Modifier, color: Color = Material
}
@Composable
private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listState: LazyListState, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<Set<String>>, connectNameCandidate: MutableState<String?>) {
private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listState: LazyListState, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<Set<String>>, connectNameCandidate: MutableState<String?>, onlineSearch: OnlineSearchModel) {
var scrollDirection by remember { mutableStateOf(ScrollDirection.Idle) }
var previousIndex by remember { mutableStateOf(0) }
var previousScrollOffset by remember { mutableStateOf(0) }
@@ -986,6 +1085,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
// which is related to [derivedStateOf]. Using safe alternative instead
// val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } }
val chats = filteredChats(searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList(), activeFilter.value)
val rows = onlineSearch.rows(shownChatIds = chats.map { it.id }.toSet())
val topPaddingToContent = topPaddingToContent(false)
val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent
LazyColumnWithScrollBar(
@@ -1014,7 +1114,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
}
.background(MaterialTheme.colors.background)
) {
ChatListSearchBarWithTags(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate)
ChatListSearchBarWithTags(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate, onlineSearch)
}
}
if (!oneHandUICardShown.value) {
@@ -1081,6 +1181,8 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
} }
ChatListNavLinkView(chat, nextChatSelected)
}
nameRows(rows.names)
directoryRows(rows, onlineSearch)
if (!addressCreationCardShown.value) {
item {
ChatListFeatureCards()
@@ -1090,7 +1192,8 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
item { Spacer(if (oneHandUI.value) Modifier.windowInsetsTopHeight(WindowInsets.statusBars) else Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) }
}
}
if (chats.isEmpty() && chatModel.chats.value.isNotEmpty()) {
// this covers the whole list, so it must not hide the online results or their messages
if (chats.isEmpty() && chatModel.chats.value.isNotEmpty() && !onlineSearch.searching && rows.isEmpty) {
Box(Modifier.fillMaxSize().imePadding().padding(horizontal = DEFAULT_PADDING), contentAlignment = Alignment.Center) {
NoChatsView(searchText = searchText)
}
@@ -1114,25 +1217,25 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
}
@Composable
private fun ChatListSearchBarWithTags(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<Set<String>>, connectNameCandidate: MutableState<String?>) {
private fun ChatListSearchBarWithTags(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<Set<String>>, connectNameCandidate: MutableState<String?>, onlineSearch: OnlineSearchModel) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
if (oneHandUI.value) {
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) {
Divider()
// bottom toolbar: search bar below, so on desktop the connect row goes below the tags
TagsOrConnectByName(searchText, connectNameCandidate) { candidate ->
// bottom toolbar: search bar below, so on desktop the search rows go below the tags
TagsOrConnectByName(searchText, searchShowingSimplexLink, connectNameCandidate, onlineSearch) { rows ->
TagsView(searchText)
Divider()
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
rows()
}
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate)
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate, onlineSearch)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}
} else {
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate)
// top toolbar: search bar above, so on desktop the connect row goes above the tags
TagsOrConnectByName(searchText, connectNameCandidate) { candidate ->
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate, onlineSearch)
// top toolbar: search bar above, so on desktop the search rows go above the tags
TagsOrConnectByName(searchText, searchShowingSimplexLink, connectNameCandidate, onlineSearch) { rows ->
rows()
Divider()
TagsView(searchText)
}
@@ -1192,20 +1295,59 @@ internal suspend fun knownChatId(rhId: Long?, result: ConnectionPlanResult?): St
else -> null
}
// The list tags and the connect-by-name row share one slot. When there is no name, the tags show; on
// mobile the row replaces the tags while shown. On desktop both show, arranged by the caller (which
// knows whether the search bar is above or below), passed as desktopView.
// The list tags share one slot with the search rows (connect by name, search in directory); when no
// row shows, the tags show. On mobile with the bottom toolbar the rows replace the tags; with the top
// toolbar the connect row replaces the tags and the directory row follows them. On desktop the tags
// always show, with the rows arranged by the caller (which knows whether the search bar is above or
// below), passed as desktopView.
@Composable
private fun TagsOrConnectByName(
searchText: MutableState<TextFieldValue>,
searchShowingSimplexLink: State<Boolean>,
connectNameCandidate: MutableState<String?>,
desktopView: @Composable (candidate: String) -> Unit,
onlineSearch: OnlineSearchModel,
desktopView: @Composable (rows: @Composable () -> Unit) -> Unit,
) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val candidate = connectNameCandidate.value
val searchInDirectoryRow: @Composable () -> Unit = {
SearchInDirectoryRow(onSearch = { runDirectorySearch(searchText, searchShowingSimplexLink, onlineSearch) })
}
when {
candidate == null -> TagsView(searchText)
!appPlatform.isDesktop -> ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
else -> desktopView(candidate)
appPlatform.isDesktop ->
if ((candidate != null && onlineSearch.names.isEmpty()) || (searchTrimmed(searchText).isNotEmpty() && !searchShowingSimplexLink.value)) {
desktopView {
if (candidate != null && onlineSearch.names.isEmpty()) {
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
}
if (searchTrimmed(searchText).isNotEmpty() && !searchShowingSimplexLink.value) {
searchInDirectoryRow()
}
}
} else {
TagsView(searchText)
}
oneHandUI.value ->
if (candidate != null && onlineSearch.names.isEmpty()) {
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
searchInDirectoryRow()
// a SimpleX link connects on its own, so the directory is not offered for one
} else if (searchTrimmed(searchText).isNotEmpty() && !searchShowingSimplexLink.value) {
searchInDirectoryRow()
} else {
TagsView(searchText)
}
else -> {
if (candidate != null && onlineSearch.names.isEmpty()) {
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
} else {
TagsView(searchText)
}
if (searchTrimmed(searchText).isNotEmpty() && !searchShowingSimplexLink.value) {
Divider()
searchInDirectoryRow()
}
}
}
}