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() + } + } } }