mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-20 14:32:35 +00:00
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:
+179
-37
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user