From 15f2326704b198568dbbaceb111446bec48d0636 Mon Sep 17 00:00:00 2001 From: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:40:52 +0000 Subject: [PATCH] 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 --- .../common/views/chatlist/ChatListView.kt | 216 +++++++++++++++--- 1 file changed, 179 insertions(+), 37 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 4ba11fe1e4..a6104df013 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -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>(emptySet()) } val connectNameCandidate = remember { mutableStateOf(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, listState: LazyListState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState>, connectNameCandidate: MutableState) { +private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState, listState: LazyListState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState>, connectNameCandidate: MutableState, 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) { + 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, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState>, connectNameCandidate: MutableState) { +private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState>, connectNameCandidate: MutableState, 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): 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, searchShowingSimplexLink: State, onlineSearch: OnlineSearchModel) { + val text = searchTrimmed(searchText) + if (text.isEmpty() || searchShowingSimplexLink.value) return + confirmDirectorySearch { withApi { onlineSearch.searchDirectory(text) } } +} + +private fun runOnlineSearch(searchText: State, searchShowingSimplexLink: State, connectNameCandidate: State, 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, listState: LazyListState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState>, connectNameCandidate: MutableState) { +private fun BoxScope.ChatList(searchText: MutableState, listState: LazyListState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState>, connectNameCandidate: MutableState, 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, 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, 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, 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, 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, listStat } @Composable -private fun ChatListSearchBarWithTags(listState: LazyListState, searchText: MutableState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState>, connectNameCandidate: MutableState) { +private fun ChatListSearchBarWithTags(listState: LazyListState, searchText: MutableState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState>, connectNameCandidate: MutableState, 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, + searchShowingSimplexLink: State, connectNameCandidate: MutableState, - 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() + } + } } }