mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 17:13:10 +00:00
android, desktop: online search shows one list of directory results with Open, and asks to enable Directory search
The same change as on iOS: the Search key and "Search in Directory" search the directory only, "Connect to …" stays next to the results, the first search asks to enable Directory search (a switch in Privacy settings), results form one list with your chats first and the others at the chat row's icon size with Open, which opens the prepared chat without the alert, the load row says how many results are left, and a long press or right click shows Open. The search fields no longer autocorrect or capitalize. Run: 011-search-redesign, slice 4
This commit is contained in:
1 parent
00fa486aae
commit
dc1b687e3e
10 files changed
+237
-375
No files matched your search
+22
-67
@@ -3,8 +3,6 @@ package chat.simplex.common.model
|
||||
import chat.simplex.common.views.chatlist.DirectoryFooter
|
||||
import chat.simplex.common.views.newchat.ConnectTarget
|
||||
import chat.simplex.common.views.newchat.strConnectTarget
|
||||
import chat.simplex.res.*
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -66,6 +64,7 @@ data class DirectorySearchResults(
|
||||
val entries: List<DirectorySearchEntry>,
|
||||
// opaque: stored and echoed back on the next request, never inspected
|
||||
val cursor: JsonObject?,
|
||||
val moreCount: Int?,
|
||||
)
|
||||
|
||||
fun directorySearchRequest(text: String, cursor: JsonObject?): JsonObject = buildJsonObject {
|
||||
@@ -82,7 +81,9 @@ fun parseDirectorySearchResponse(resp: JsonObject): DirectorySearchResults? =
|
||||
val entries = (resp["entries"] as? JsonArray)?.mapNotNull {
|
||||
runCatching { json.decodeFromJsonElement<DirectorySearchEntry>(it) }.getOrNull()
|
||||
} ?: emptyList()
|
||||
DirectorySearchResults(entries, resp["searchCursor"] as? JsonObject)
|
||||
// only shown as text, so a count out of range is dropped rather than failing the response
|
||||
val moreCount = (resp["moreCount"] as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull?.takeIf { it >= 0 }
|
||||
DirectorySearchResults(entries, resp["searchCursor"] as? JsonObject, moreCount)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
@@ -109,82 +110,36 @@ fun planLocalChatId(plan: ConnectionPlan): ChatId? =
|
||||
is ConnectionPlan.Error -> null
|
||||
}
|
||||
|
||||
class NameSearchResult private constructor(
|
||||
val target: String,
|
||||
val simplexName: String,
|
||||
val profileName: String?,
|
||||
val descr: String?,
|
||||
val image: String?,
|
||||
val iconName: ImageResource,
|
||||
val localChatId: ChatId?,
|
||||
// the link the core resolved the name to, never a profile field the name's owner wrote
|
||||
val resolvedLink: String?,
|
||||
) {
|
||||
val id: String get() = target
|
||||
sealed class DirectoryResultState {
|
||||
data class YourChat(val chatId: ChatId): DirectoryResultState()
|
||||
object Joining: DirectoryResultState()
|
||||
object Open: DirectoryResultState()
|
||||
}
|
||||
|
||||
fun withLocalChatId(localChatId: ChatId?): NameSearchResult =
|
||||
NameSearchResult(target, simplexName, profileName, descr, image, iconName, localChatId, resolvedLink)
|
||||
|
||||
companion object {
|
||||
operator fun invoke(target: String, result: ConnectionPlanResult): NameSearchResult? {
|
||||
val profile: NamedChat?
|
||||
val iconName: ImageResource
|
||||
val resolvedLink: String?
|
||||
when (val connectionPlan = result.connectionPlan) {
|
||||
is ConnectionPlan.ContactAddress -> {
|
||||
profile = when (val contactAddressPlan = connectionPlan.contactAddressPlan) {
|
||||
is ContactAddressPlan.Ok -> contactAddressPlan.contactSLinkData_?.profile
|
||||
is ContactAddressPlan.ContactViaAddress -> contactAddressPlan.contact.profile
|
||||
is ContactAddressPlan.ConnectingProhibit -> contactAddressPlan.contact.profile
|
||||
is ContactAddressPlan.Known -> contactAddressPlan.contact.profile
|
||||
ContactAddressPlan.ConnectingConfirmReconnect -> null
|
||||
ContactAddressPlan.OwnLink -> return null
|
||||
}
|
||||
iconName = MR.images.ic_account_circle_filled
|
||||
resolvedLink = null
|
||||
}
|
||||
is ConnectionPlan.GroupLink -> {
|
||||
val groupProfile: GroupProfile? = when (val groupLinkPlan = connectionPlan.groupLinkPlan) {
|
||||
is GroupLinkPlan.Ok -> groupLinkPlan.groupSLinkData_?.groupProfile
|
||||
is GroupLinkPlan.NoRelays -> groupLinkPlan.groupSLinkData_?.groupProfile
|
||||
is GroupLinkPlan.UpdateRequired -> groupLinkPlan.groupSLinkData_?.groupProfile
|
||||
is GroupLinkPlan.Known -> groupLinkPlan.groupInfo.groupProfile
|
||||
is GroupLinkPlan.OwnLink -> groupLinkPlan.groupInfo.groupProfile
|
||||
is GroupLinkPlan.ConnectingProhibit -> groupLinkPlan.groupInfo_?.groupProfile
|
||||
GroupLinkPlan.ConnectingConfirmReconnect -> null
|
||||
}
|
||||
profile = groupProfile
|
||||
iconName = if (groupProfile?.isChannel == true) MR.images.ic_bigtop_updates_circle_filled else MR.images.ic_supervised_user_circle_filled
|
||||
resolvedLink = result.connLink.connShortLink
|
||||
}
|
||||
is ConnectionPlan.InvitationLink, is ConnectionPlan.Error -> return null
|
||||
}
|
||||
return NameSearchResult(
|
||||
target = target,
|
||||
simplexName = result.planSimplexName?.shortStr ?: target,
|
||||
profileName = profile?.displayName,
|
||||
descr = profile?.shortDescr,
|
||||
image = profile?.image,
|
||||
iconName = iconName,
|
||||
localChatId = planLocalChatId(result.connectionPlan),
|
||||
resolvedLink = resolvedLink,
|
||||
)
|
||||
}
|
||||
// no plan means the store doesn't know the link, or the plan failed and Open's online plan will show why
|
||||
fun directoryResultState(plan: ConnectionPlan?): DirectoryResultState {
|
||||
val chatId = plan?.let { planLocalChatId(it) }
|
||||
if (chatId != null) return DirectoryResultState.YourChat(chatId)
|
||||
val groupLinkPlan = (plan as? ConnectionPlan.GroupLink)?.groupLinkPlan
|
||||
return if (groupLinkPlan == GroupLinkPlan.ConnectingConfirmReconnect || (groupLinkPlan is GroupLinkPlan.ConnectingProhibit && groupLinkPlan.groupInfo_ == null)) {
|
||||
DirectoryResultState.Joining
|
||||
} else {
|
||||
DirectoryResultState.Open
|
||||
}
|
||||
}
|
||||
|
||||
data class DirectoryResult(
|
||||
val entry: DirectorySearchEntry,
|
||||
val localChatId: ChatId?,
|
||||
val state: DirectoryResultState,
|
||||
) {
|
||||
val id: String get() = entry.id
|
||||
}
|
||||
|
||||
data class OnlineSearchRows(
|
||||
val names: List<NameSearchResult>,
|
||||
val directory: List<DirectoryResult>,
|
||||
val yourChats: List<ChatId>,
|
||||
val results: List<DirectoryResult>,
|
||||
val pageInChats: Boolean,
|
||||
val footer: DirectoryFooter?,
|
||||
) {
|
||||
val isEmpty: Boolean get() = names.isEmpty() && directory.isEmpty() && !pageInChats && footer == null
|
||||
val isEmpty: Boolean get() = yourChats.isEmpty() && results.isEmpty() && !pageInChats && footer == null
|
||||
}
|
||||
+2
-3
@@ -125,6 +125,7 @@ class AppPreferences {
|
||||
val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true)
|
||||
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
|
||||
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
|
||||
val privacyDirectorySearch = mkBoolPreference(SHARED_PREFS_PRIVACY_DIRECTORY_SEARCH, false)
|
||||
val privacyVerifySimplexNames = mkBoolPreference(SHARED_PREFS_PRIVACY_VERIFY_SIMPLEX_NAMES, false)
|
||||
val privacyLinkPreviewsShowAlert = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT, true)
|
||||
val privacySanitizeLinks = mkBoolPreference(SHARED_PREFS_PRIVACY_SANITIZE_LINKS, false)
|
||||
@@ -196,7 +197,6 @@ class AppPreferences {
|
||||
val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true)
|
||||
val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false)
|
||||
val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false)
|
||||
val directorySearchAlertShown = mkBoolPreference(SHARED_PREFS_DIRECTORY_SEARCH_ALERT_SHOWN, false)
|
||||
val supporterBannerShown = mkBoolPreference(SHARED_PREFS_SUPPORTER_BANNER_SHOWN, false)
|
||||
val supporterBannerTapped = mkBoolPreference(SHARED_PREFS_SUPPORTER_BANNER_TAPPED, false)
|
||||
val getStakeBannerTapped = mkBoolPreference(SHARED_PREFS_GET_STAKE_BANNER_TAPPED, false)
|
||||
@@ -283,7 +283,6 @@ class AppPreferences {
|
||||
hintPref(laNoticeShown, false),
|
||||
hintPref(oneHandUICardShown, false),
|
||||
hintPref(addressCreationCardShown, false),
|
||||
hintPref(directorySearchAlertShown, false),
|
||||
hintPref(supporterBannerShown, false),
|
||||
hintPref(supporterBannerTapped, false),
|
||||
hintPref(getStakeBannerTapped, false),
|
||||
@@ -417,6 +416,7 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
|
||||
private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
|
||||
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
|
||||
private const val SHARED_PREFS_PRIVACY_DIRECTORY_SEARCH = "PrivacyDirectorySearch"
|
||||
private const val SHARED_PREFS_PRIVACY_VERIFY_SIMPLEX_NAMES = "PrivacyVerifySimplexNames"
|
||||
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT = "PrivacyLinkPreviewsShowAlert"
|
||||
private const val SHARED_PREFS_PRIVACY_SANITIZE_LINKS = "PrivacySanitizeLinks"
|
||||
@@ -480,7 +480,6 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice"
|
||||
private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown"
|
||||
private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown"
|
||||
private const val SHARED_PREFS_DIRECTORY_SEARCH_ALERT_SHOWN = "DirectorySearchAlertShown"
|
||||
private const val SHARED_PREFS_SUPPORTER_BANNER_SHOWN = "SupporterBannerShown"
|
||||
private const val SHARED_PREFS_SUPPORTER_BANNER_TAPPED = "SupporterBannerTapped"
|
||||
private const val SHARED_PREFS_GET_STAKE_BANNER_TAPPED = "GetStakeBannerTapped"
|
||||
|
||||
+11
@@ -2352,6 +2352,17 @@ fun BoxScope.ChatItemsList(
|
||||
)
|
||||
}
|
||||
|
||||
val subscriberCount = if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.nextConnectPrepared && chatInfo.groupInfo.useRelays) chatInfo.groupInfo.groupSummary.publicMemberCount else null
|
||||
if (subscriberCount != null) {
|
||||
Text(
|
||||
subscriberCountStr(subscriberCount),
|
||||
style = MaterialTheme.typography.body2,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.padding(top = DEFAULT_PADDING_HALF)
|
||||
)
|
||||
}
|
||||
|
||||
ProfileDescriptionText(
|
||||
shortDescr = chatInfo.shortDescr,
|
||||
description = chatInfo.profileDescription,
|
||||
|
||||
+28
-92
@@ -472,19 +472,12 @@ private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState<TextFiel
|
||||
if (onlineSearch.showResults) {
|
||||
val rows = onlineSearch.rows(shownChatIds = emptySet())
|
||||
val resultsState = rememberLazyListState()
|
||||
val hasNames = rows.names.isNotEmpty()
|
||||
// the list keeps its first visible row in place by key, so in one-hand UI names arriving after
|
||||
// the directory page would be inserted below that row, out of view
|
||||
LaunchedEffect(hasNames) {
|
||||
if (hasNames && oneHandUI.value) resultsState.scrollToItem(0)
|
||||
}
|
||||
LazyColumnWithScrollBarNoAppBar(
|
||||
if (!oneHandUI.value) Modifier.fillMaxSize().imePadding() else Modifier.fillMaxSize(),
|
||||
state = resultsState,
|
||||
reverseLayout = oneHandUI.value
|
||||
) {
|
||||
nameRows(rows.names, onlineSearch, oneHandUI.value)
|
||||
directoryRows(rows, onlineSearch, oneHandUI.value)
|
||||
directoryRows(rows, onlineSearch)
|
||||
if (!onlineSearch.searching && rows.isEmpty) {
|
||||
item {
|
||||
Text(
|
||||
@@ -514,72 +507,21 @@ private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState<TextFiel
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.nameRows(names: List<NameSearchResult>, onlineSearch: OnlineSearchModel, oneHandUI: Boolean) {
|
||||
if (names.isNotEmpty()) {
|
||||
searchPart(MR.strings.online_search_names, oneHandUI) {
|
||||
items(names, key = { it.id }) { result ->
|
||||
val view = LocalMultiplatformView()
|
||||
NameSearchRow(
|
||||
result,
|
||||
Modifier.clickable {
|
||||
hideKeyboard(view)
|
||||
withBGApi { planAndConnect(chatModel.remoteHostId(), result.target, close = null, cleanup = { withApi { onlineSearch.refreshLocalChatIds() } }) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
private fun LazyListScope.directoryRows(rows: OnlineSearchRows, onlineSearch: OnlineSearchModel) {
|
||||
items(rows.yourChats.mapNotNull { chatModel.getChat(it) }, key = { it.remoteHostId to it.id }) { chat ->
|
||||
ChatListNavLinkView(chat, remember { mutableStateOf(false) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.directoryRows(rows: OnlineSearchRows, onlineSearch: OnlineSearchModel, oneHandUI: Boolean) {
|
||||
if (rows.directory.isNotEmpty() || rows.pageInChats || rows.footer != null) {
|
||||
searchPart(MR.strings.online_search_directory, oneHandUI) {
|
||||
items(rows.directory, key = { it.id }) { result ->
|
||||
val chat = result.localChatId?.let { chatModel.getChat(it) }
|
||||
if (chat != null) {
|
||||
ChatListNavLinkView(chat, remember { mutableStateOf(false) })
|
||||
} else {
|
||||
val view = LocalMultiplatformView()
|
||||
DirectorySearchRow(
|
||||
result.entry,
|
||||
Modifier.clickable {
|
||||
hideKeyboard(view)
|
||||
withBGApi { planAndConnect(chatModel.remoteHostId(), result.entry.link, close = null, cleanup = { withApi { onlineSearch.refreshLocalChatIds() } }) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (rows.pageInChats) {
|
||||
item { DirectoryPageInChatsRow() }
|
||||
}
|
||||
when (rows.footer) {
|
||||
DirectoryFooter.Retry -> item { DirectoryRetryRow(onlineSearch) }
|
||||
DirectoryFooter.ShowMore -> item { DirectoryShowMoreRow(onlineSearch) }
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
items(rows.results, key = { it.id }) { result ->
|
||||
DirectoryResultNavLink(result, imageSize = 72.dp * fontSizeSqrtMultiplier, close = null, cleanup = { withApi { onlineSearch.refreshStates() } })
|
||||
}
|
||||
}
|
||||
|
||||
// the list is reversed in one-hand UI, so there the header follows its rows to be drawn above them
|
||||
internal fun LazyListScope.searchPart(title: StringResource, oneHandUI: Boolean, header: @Composable (String) -> Unit = { SearchPartHeader(it) }, content: LazyListScope.() -> Unit) {
|
||||
if (oneHandUI) {
|
||||
content()
|
||||
item { header(stringResource(title)) }
|
||||
} else {
|
||||
item { header(stringResource(title)) }
|
||||
content()
|
||||
if (rows.pageInChats) {
|
||||
item { DirectoryPageInChatsRow() }
|
||||
}
|
||||
when (rows.footer) {
|
||||
DirectoryFooter.Retry -> item { DirectoryRetryRow(onlineSearch) }
|
||||
DirectoryFooter.LoadMore -> item { DirectoryLoadMoreRow(onlineSearch) }
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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
|
||||
@@ -941,7 +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) },
|
||||
onSearch = { runDirectorySearch(searchText, searchShowingSimplexLink, onlineSearch) },
|
||||
) {
|
||||
searchText.value = searchText.value.copy(it)
|
||||
}
|
||||
@@ -1043,20 +985,13 @@ 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
|
||||
// whatever is typed here gets sent to the directory, so ask until Directory search is enabled
|
||||
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) {
|
||||
@@ -1240,8 +1175,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
} }
|
||||
ChatListNavLinkView(chat, nextChatSelected)
|
||||
}
|
||||
nameRows(rows.names, onlineSearch, oneHandUI.value)
|
||||
directoryRows(rows, onlineSearch, oneHandUI.value)
|
||||
directoryRows(rows, onlineSearch)
|
||||
if (showsCards && !addressCreationCardShown.value) {
|
||||
item {
|
||||
ChatListFeatureCards()
|
||||
@@ -1372,14 +1306,17 @@ private fun TagsOrConnectByName(
|
||||
val searchInDirectoryRow: @Composable () -> Unit = {
|
||||
SearchInDirectoryRow(onSearch = { runDirectorySearch(searchText, searchShowingSimplexLink, onlineSearch) })
|
||||
}
|
||||
// a SimpleX link connects on its own, so the directory is not offered for one
|
||||
val searchesText = searchTrimmed(searchText).isNotEmpty() && !searchShowingSimplexLink.value
|
||||
val showsSearchInDirectory = searchesText && !onlineSearch.showResults
|
||||
when {
|
||||
appPlatform.isDesktop ->
|
||||
if ((candidate != null && onlineSearch.names.isEmpty()) || (searchTrimmed(searchText).isNotEmpty() && !searchShowingSimplexLink.value)) {
|
||||
if (candidate != null || showsSearchInDirectory) {
|
||||
desktopView {
|
||||
if (candidate != null && onlineSearch.names.isEmpty()) {
|
||||
if (candidate != null) {
|
||||
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
|
||||
}
|
||||
if (searchTrimmed(searchText).isNotEmpty() && !searchShowingSimplexLink.value) {
|
||||
if (showsSearchInDirectory) {
|
||||
searchInDirectoryRow()
|
||||
}
|
||||
}
|
||||
@@ -1387,22 +1324,21 @@ private fun TagsOrConnectByName(
|
||||
TagsView(searchText)
|
||||
}
|
||||
oneHandUI.value ->
|
||||
if (candidate != null && onlineSearch.names.isEmpty()) {
|
||||
if (candidate != null) {
|
||||
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()
|
||||
if (showsSearchInDirectory) searchInDirectoryRow()
|
||||
} else if (searchesText) {
|
||||
if (showsSearchInDirectory) searchInDirectoryRow()
|
||||
} else {
|
||||
TagsView(searchText)
|
||||
}
|
||||
else -> {
|
||||
if (candidate != null && onlineSearch.names.isEmpty()) {
|
||||
if (candidate != null) {
|
||||
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
|
||||
} else {
|
||||
TagsView(searchText)
|
||||
}
|
||||
if (searchTrimmed(searchText).isNotEmpty() && !searchShowingSimplexLink.value) {
|
||||
if (showsSearchInDirectory) {
|
||||
Divider()
|
||||
searchInDirectoryRow()
|
||||
}
|
||||
|
||||
+106
-131
@@ -11,37 +11,35 @@ 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 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.item.ItemAction
|
||||
import chat.simplex.common.views.chat.subscriberCountStr
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.PrepareGroupMode
|
||||
import chat.simplex.common.views.newchat.planAndConnect
|
||||
import chat.simplex.res.*
|
||||
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 }
|
||||
enum class DirectoryFooter { Retry, LoadMore }
|
||||
|
||||
// Online results are not chats and are never saved: they live as long as the search text does.
|
||||
// main thread only, as iOS's @MainActor: callers launch with withApi, and the generation checks rely on it
|
||||
class OnlineSearchModel {
|
||||
var names by mutableStateOf<List<NameSearchResult>>(emptyList())
|
||||
private set
|
||||
var directory by mutableStateOf<List<DirectoryResult>>(emptyList())
|
||||
private set
|
||||
var moreCount by mutableStateOf<Int?>(null)
|
||||
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"
|
||||
@@ -55,18 +53,17 @@ class OnlineSearchModel {
|
||||
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
|
||||
val searching: Boolean get() = loading
|
||||
val showResults: Boolean get() = loading || searched
|
||||
val footer: DirectoryFooter? get() = if (failed) DirectoryFooter.Retry else if (cursor != null) DirectoryFooter.LoadMore else null
|
||||
|
||||
fun reset() {
|
||||
generation += 1
|
||||
names = emptyList()
|
||||
directory = emptyList()
|
||||
moreCount = null
|
||||
cursor = null
|
||||
lastPageIds = emptySet()
|
||||
loading = false
|
||||
resolvingNames = false
|
||||
failed = false
|
||||
searched = false
|
||||
searchedText = ""
|
||||
@@ -76,26 +73,6 @@ class OnlineSearchModel {
|
||||
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) {
|
||||
@@ -103,6 +80,7 @@ class OnlineSearchModel {
|
||||
if (text.isEmpty() || (loading && text == searchedText)) return
|
||||
if (text == searchedText) {
|
||||
directory = emptyList()
|
||||
moreCount = null
|
||||
cursor = null
|
||||
lastPageIds = emptySet()
|
||||
failed = false
|
||||
@@ -119,7 +97,7 @@ class OnlineSearchModel {
|
||||
request(append = true)
|
||||
}
|
||||
|
||||
// a failed "Show more" keeps its cursor, so the retry resumes the page rather than starting over
|
||||
// a failed "Load more" keeps its cursor, so the retry resumes the page rather than starting over
|
||||
suspend fun retry() {
|
||||
if (cursor != null) {
|
||||
loadMore()
|
||||
@@ -129,62 +107,49 @@ class OnlineSearchModel {
|
||||
}
|
||||
|
||||
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
|
||||
val yourChats = mutableListOf<ChatId>()
|
||||
val results = mutableListOf<DirectoryResult>()
|
||||
for (result in directory) {
|
||||
val state = result.state
|
||||
when {
|
||||
state is DirectoryResultState.YourChat && chatModel.getChat(state.chatId) != null ->
|
||||
if (!shownChatIds.contains(state.chatId) && !yourChats.contains(state.chatId)) {
|
||||
yourChats.add(state.chatId)
|
||||
}
|
||||
state is DirectoryResultState.YourChat ->
|
||||
results.add(result.copy(state = DirectoryResultState.Open))
|
||||
else ->
|
||||
results.add(result)
|
||||
}
|
||||
}
|
||||
val lastPage = directory.filter { lastPageIds.contains(it.id) }
|
||||
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)
|
||||
},
|
||||
// a result hidden only because a name resolved to its link is on screen, but not one of your chats
|
||||
yourChats = yourChats,
|
||||
results = results,
|
||||
pageInChats = lastPage.isNotEmpty() && lastPage.all {
|
||||
isAmong(shownChatIds, it.localChatId) || isAmong(nameChatIds, it.localChatId)
|
||||
val state = it.state
|
||||
state is DirectoryResultState.YourChat && shownChatIds.contains(state.chatId)
|
||||
},
|
||||
footer = footer
|
||||
)
|
||||
}
|
||||
|
||||
// a chat opened from a result exists only once its connection flow ends, so rows without a chat look again then
|
||||
suspend fun refreshLocalChatIds() {
|
||||
// a chat opened from a result exists only once its connection flow ends, so results look again then
|
||||
suspend fun refreshStates() {
|
||||
val rh = chatModel.remoteHostId()
|
||||
val gen = generation
|
||||
val nameChatIds = mutableMapOf<String, ChatId>()
|
||||
for (result in names) {
|
||||
if (result.localChatId == null) localChatId(rh, result.target)?.let { nameChatIds[result.id] = it }
|
||||
}
|
||||
val directoryChatIds = mutableMapOf<String, ChatId>()
|
||||
val states = mutableMapOf<String, DirectoryResultState>()
|
||||
for (result in directory) {
|
||||
if (result.localChatId == null) localChatId(rh, result.entry.link)?.let { directoryChatIds[result.id] = it }
|
||||
if (result.state is DirectoryResultState.YourChat) continue
|
||||
states[result.id] = resultState(rh, result.entry.link)
|
||||
}
|
||||
if (gen != generation) return
|
||||
names = names.map { r -> r.withLocalChatId(r.localChatId ?: nameChatIds[r.id]) }
|
||||
directory = directory.map { r -> r.copy(localChatId = r.localChatId ?: directoryChatIds[r.id]) }
|
||||
directory = directory.map { r -> r.copy(state = states[r.id] ?: r.state) }
|
||||
}
|
||||
|
||||
private suspend fun localChatId(rh: Long?, link: String): ChatId? {
|
||||
private suspend fun resultState(rh: Long?, link: String): DirectoryResultState {
|
||||
val plan = chatModel.controller.apiConnectPlan(rh, link, resolveMode = PlanResolveMode.PRMNever, inProgress = mutableStateOf(false), onFailure = ConnectPlanFailure.Log)
|
||||
return plan?.let { planLocalChatId(it.connectionPlan) }
|
||||
}
|
||||
|
||||
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
|
||||
return directoryResultState(plan?.connectionPlan)
|
||||
}
|
||||
|
||||
private suspend fun request(append: Boolean) {
|
||||
@@ -198,7 +163,7 @@ class OnlineSearchModel {
|
||||
val seen = directory.map { it.id }.toMutableSet()
|
||||
for (entry in r.entries) {
|
||||
if (!seen.add(entry.id)) continue
|
||||
page.add(DirectoryResult(entry = entry, localChatId = localChatId(rh, entry.link)))
|
||||
page.add(DirectoryResult(entry = entry, state = resultState(rh, entry.link)))
|
||||
}
|
||||
}
|
||||
if (gen != generation) return
|
||||
@@ -209,6 +174,7 @@ class OnlineSearchModel {
|
||||
return
|
||||
}
|
||||
cursor = r.cursor
|
||||
moreCount = r.moreCount
|
||||
directory = if (append) directory + page else page
|
||||
lastPageIds = page.map { it.id }.toSet()
|
||||
}
|
||||
@@ -248,19 +214,20 @@ fun SearchInDirectoryRow(onSearch: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DirectorySearchRow(entry: DirectorySearchEntry, modifier: Modifier = Modifier) {
|
||||
fun DirectorySearchRow(entry: DirectorySearchEntry, imageSize: Dp, state: DirectoryResultState, modifier: Modifier = Modifier) {
|
||||
val displayName = "#" + entry.displayName
|
||||
val isChannel = entry.entryType.groupType == GroupType.Channel
|
||||
val joining = state == DirectoryResultState.Joining
|
||||
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),
|
||||
modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ProfileImage(
|
||||
size = 42.dp,
|
||||
size = imageSize,
|
||||
image = entry.image,
|
||||
icon = if (isChannel) MR.images.ic_bigtop_updates_circle_filled else MR.images.ic_supervised_user_circle_filled
|
||||
)
|
||||
@@ -283,43 +250,19 @@ fun DirectorySearchRow(entry: DirectorySearchEntry, modifier: Modifier = Modifie
|
||||
if (descr != null && descr.isNotEmpty()) {
|
||||
Text(descr, color = MaterialTheme.colors.secondary, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Text(membersText, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary)
|
||||
Text(
|
||||
if (joining) stringResource(MR.strings.directory_search_already_joining) else membersText,
|
||||
style = MaterialTheme.typography.body2,
|
||||
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)
|
||||
}
|
||||
if (!joining) {
|
||||
Text(
|
||||
stringResource(MR.strings.open_verb),
|
||||
color = MaterialTheme.colors.primary,
|
||||
maxLines = 1,
|
||||
softWrap = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,18 +279,23 @@ fun DirectoryPageInChatsRow() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DirectoryShowMoreRow(onlineSearch: OnlineSearchModel) {
|
||||
fun DirectoryLoadMoreRow(onlineSearch: OnlineSearchModel) {
|
||||
val moreCount = onlineSearch.moreCount
|
||||
val color = if (onlineSearch.loading) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = !onlineSearch.loading) {
|
||||
withApi { onlineSearch.loadMore() }
|
||||
}
|
||||
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)
|
||||
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(painterResource(MR.images.ic_search), null, Modifier.padding(end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier), tint = color)
|
||||
Text(
|
||||
stringResource(MR.strings.directory_search_show_more),
|
||||
color = if (onlineSearch.loading) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||
if (moreCount != null && moreCount > 0) String.format(generalGetString(MR.strings.directory_search_load_more_n_left), moreCount)
|
||||
else stringResource(MR.strings.directory_search_load_more),
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -360,14 +308,47 @@ fun DirectoryRetryRow(onlineSearch: OnlineSearchModel) {
|
||||
.clickable {
|
||||
withApi { onlineSearch.retry() }
|
||||
}
|
||||
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)
|
||||
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(painterResource(MR.images.ic_refresh), null, Modifier.padding(end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.primary)
|
||||
Text(stringResource(MR.strings.directory_search_failed_retry), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
// a result as a chat row: the same padding and divider, and the long-press / right-click menu with Open
|
||||
@Composable
|
||||
fun DirectoryResultNavLink(result: DirectoryResult, imageSize: Dp, previewModifier: Modifier = Modifier, close: (() -> Unit)?, cleanup: (() -> Unit)?) {
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
val view = LocalMultiplatformView()
|
||||
val open = {
|
||||
hideKeyboard(view)
|
||||
openDirectoryResult(result, close, cleanup)
|
||||
}
|
||||
ChatListNavLinkLayout(
|
||||
chatLinkPreview = { DirectorySearchRow(result.entry, imageSize, result.state, previewModifier) },
|
||||
click = open,
|
||||
dropdownMenuItems = {
|
||||
ItemAction(stringResource(MR.strings.open_verb), painterResource(MR.images.ic_chat_bubble), onClick = {
|
||||
showMenu.value = false
|
||||
open()
|
||||
})
|
||||
},
|
||||
showMenu = showMenu,
|
||||
disabled = false,
|
||||
selectedChat = remember { mutableStateOf(false) },
|
||||
nextChatSelected = remember { mutableStateOf(false) },
|
||||
)
|
||||
}
|
||||
|
||||
// a pending join keeps the alert the connection flow shows for it
|
||||
fun openDirectoryResult(result: DirectoryResult, close: (() -> Unit)?, cleanup: (() -> Unit)?) {
|
||||
val prepareGroup = if (result.state == DirectoryResultState.Joining) PrepareGroupMode.Confirm else PrepareGroupMode.Open
|
||||
withBGApi { planAndConnect(chatModel.remoteHostId(), result.entry.link, close = close, cleanup = cleanup, prepareGroup = prepareGroup) }
|
||||
}
|
||||
|
||||
fun confirmDirectorySearch(search: () -> Unit) {
|
||||
if (appPrefs.directorySearchAlertShown.get()) {
|
||||
if (appPrefs.privacyDirectorySearch.get()) {
|
||||
search()
|
||||
} else {
|
||||
showDirectorySearchAlert(onSearch = search)
|
||||
@@ -376,23 +357,17 @@ fun confirmDirectorySearch(search: () -> Unit) {
|
||||
|
||||
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),
|
||||
title = generalGetString(MR.strings.enable_directory_search_question),
|
||||
text = generalGetString(MR.strings.enable_directory_search_alert_desc),
|
||||
parseHtml = false,
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
appPrefs.privacyDirectorySearch.set(true)
|
||||
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)
|
||||
Text(stringResource(MR.strings.enable_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ fun SearchTextField(
|
||||
enabled = rememberUpdatedState(enabled).value,
|
||||
cursorBrush = SolidColor(colors.cursorColor(false).value),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrect = false, imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = if (onSearch != null && appPlatform.isAndroid) {
|
||||
{
|
||||
keyboard?.hide()
|
||||
|
||||
+32
-13
@@ -21,6 +21,10 @@ enum class ConnectionLinkType {
|
||||
INVITATION, CONTACT, GROUP
|
||||
}
|
||||
|
||||
enum class PrepareGroupMode {
|
||||
Confirm, Open
|
||||
}
|
||||
|
||||
suspend fun planAndConnect(
|
||||
rhId: Long?,
|
||||
shortOrFullLink: String,
|
||||
@@ -29,6 +33,7 @@ suspend fun planAndConnect(
|
||||
cleanup: (() -> Unit)? = null,
|
||||
filterKnownContact: ((Contact) -> Unit)? = null,
|
||||
filterKnownGroup: ((GroupInfo) -> Unit)? = null,
|
||||
prepareGroup: PrepareGroupMode = PrepareGroupMode.Confirm,
|
||||
): CompletableDeferred<Boolean> {
|
||||
when (val target = strConnectTarget(shortOrFullLink.trim())) {
|
||||
is ConnectTarget.Link -> {
|
||||
@@ -51,7 +56,7 @@ suspend fun planAndConnect(
|
||||
inProgress.value = false
|
||||
cleanup?.invoke()
|
||||
}
|
||||
return planAndConnectTask(rhId, shortOrFullLink, linkOwnerSig, close, cleanup, filterKnownContact, filterKnownGroup, inProgress)
|
||||
return planAndConnectTask(rhId, shortOrFullLink, linkOwnerSig, close, cleanup, filterKnownContact, filterKnownGroup, prepareGroup, inProgress)
|
||||
}
|
||||
|
||||
private suspend fun planAndConnectTask(
|
||||
@@ -62,6 +67,7 @@ private suspend fun planAndConnectTask(
|
||||
cleanup: (() -> Unit)? = null,
|
||||
filterKnownContact: ((Contact) -> Unit)? = null,
|
||||
filterKnownGroup: ((GroupInfo) -> Unit)? = null,
|
||||
prepareGroup: PrepareGroupMode,
|
||||
inProgress: MutableState<Boolean>
|
||||
): CompletableDeferred<Boolean> {
|
||||
val completable = CompletableDeferred<Boolean>()
|
||||
@@ -249,18 +255,31 @@ private suspend fun planAndConnectTask(
|
||||
is GroupLinkPlan.Ok ->
|
||||
if (connectionPlan.groupLinkPlan.groupSLinkData_ != null) {
|
||||
Log.d(TAG, "planAndConnect, .GroupLink, .Ok, short link data present")
|
||||
showPrepareGroupAlert(
|
||||
rhId,
|
||||
connectionLink,
|
||||
connectionPlan.groupLinkPlan.groupSLinkInfo_,
|
||||
connectionPlan.groupLinkPlan.groupSLinkData_,
|
||||
ownerVerification = connectionPlan.groupLinkPlan.ownerVerification,
|
||||
planSimplexName = planSimplexName,
|
||||
connectOtherButton = connectOtherButton,
|
||||
connectOtherLink = connectOtherLink,
|
||||
close,
|
||||
cleanup
|
||||
)
|
||||
when (prepareGroup) {
|
||||
PrepareGroupMode.Confirm ->
|
||||
showPrepareGroupAlert(
|
||||
rhId,
|
||||
connectionLink,
|
||||
connectionPlan.groupLinkPlan.groupSLinkInfo_,
|
||||
connectionPlan.groupLinkPlan.groupSLinkData_,
|
||||
ownerVerification = connectionPlan.groupLinkPlan.ownerVerification,
|
||||
planSimplexName = planSimplexName,
|
||||
connectOtherButton = connectOtherButton,
|
||||
connectOtherLink = connectOtherLink,
|
||||
close,
|
||||
cleanup
|
||||
)
|
||||
PrepareGroupMode.Open ->
|
||||
prepareAndOpenGroup(
|
||||
rhId,
|
||||
connectionLink,
|
||||
connectionPlan.groupLinkPlan.groupSLinkInfo_,
|
||||
connectionPlan.groupLinkPlan.groupSLinkData_,
|
||||
planSimplexName?.nameDomain,
|
||||
close,
|
||||
cleanup
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "planAndConnect, .GroupLink, .Ok, no short link data")
|
||||
askCurrentOrIncognitoProfileAlert(
|
||||
|
||||
+23
-62
@@ -186,55 +186,23 @@ private fun ModalData.NewChatSheetLayout(
|
||||
onlineSearch.reset()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OnlineSearchHeader(title: String) {
|
||||
SectionView(title, headerBottomPadding = DEFAULT_PADDING_HALF) {}
|
||||
}
|
||||
|
||||
// the divider is an item of its own: the reversed one-hand column also reverses the nodes inside an item,
|
||||
// which would put it between the header and its rows
|
||||
fun LazyListScope.onlineSearchSections(rows: OnlineSearchRows) {
|
||||
if (rows.names.isNotEmpty()) {
|
||||
// emitted in this order, the load row is the row farthest from the field in both layouts
|
||||
fun LazyListScope.onlineSearchSection(rows: OnlineSearchRows) {
|
||||
if (!rows.isEmpty) {
|
||||
item { SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) }
|
||||
searchPart(MR.strings.online_search_names, oneHandUI.value, header = { OnlineSearchHeader(it) }) {
|
||||
items(rows.names, key = { it.id }) { result ->
|
||||
val view = LocalMultiplatformView()
|
||||
NameSearchRow(
|
||||
result,
|
||||
Modifier.clickable {
|
||||
hideKeyboard(view)
|
||||
withBGApi { planAndConnect(chatModel.remoteHostId(), result.target, close = close, cleanup = null) }
|
||||
}
|
||||
)
|
||||
}
|
||||
items(rows.yourChats.mapNotNull { chatModel.getChat(it) }, key = { it.remoteHostId to it.id }) { chat ->
|
||||
ContactListNavLinkView(chat, remember { mutableStateOf(false) }, showDeletedChatIcon = true)
|
||||
}
|
||||
}
|
||||
if (rows.directory.isNotEmpty() || rows.pageInChats || rows.footer != null) {
|
||||
item { SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) }
|
||||
searchPart(MR.strings.online_search_directory, oneHandUI.value, header = { OnlineSearchHeader(it) }) {
|
||||
items(rows.directory, key = { it.id }) { result ->
|
||||
val chat = result.localChatId?.let { chatModel.getChat(it) }
|
||||
if (chat != null) {
|
||||
ContactListNavLinkView(chat, remember { mutableStateOf(false) }, showDeletedChatIcon = true)
|
||||
} else {
|
||||
val view = LocalMultiplatformView()
|
||||
DirectorySearchRow(
|
||||
result.entry,
|
||||
Modifier.clickable {
|
||||
hideKeyboard(view)
|
||||
withBGApi { planAndConnect(chatModel.remoteHostId(), result.entry.link, close = close, cleanup = null) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (rows.pageInChats) {
|
||||
item { DirectoryPageInChatsRow() }
|
||||
}
|
||||
when (rows.footer) {
|
||||
DirectoryFooter.Retry -> item { DirectoryRetryRow(onlineSearch) }
|
||||
DirectoryFooter.ShowMore -> item { DirectoryShowMoreRow(onlineSearch) }
|
||||
null -> {}
|
||||
}
|
||||
items(rows.results, key = { it.id }) { result ->
|
||||
DirectoryResultNavLink(result, imageSize = 42.dp, previewModifier = Modifier.padding(horizontal = DEFAULT_PADDING_HALF), close = close, cleanup = null)
|
||||
}
|
||||
if (rows.pageInChats) {
|
||||
item { DirectoryPageInChatsRow() }
|
||||
}
|
||||
when (rows.footer) {
|
||||
DirectoryFooter.Retry -> item { DirectoryRetryRow(onlineSearch) }
|
||||
DirectoryFooter.LoadMore -> item { DirectoryLoadMoreRow(onlineSearch) }
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,13 +215,6 @@ private fun ModalData.NewChatSheetLayout(
|
||||
confirmDirectorySearch { withApi { onlineSearch.searchDirectory(text) } }
|
||||
}
|
||||
|
||||
fun runOnlineSearch() {
|
||||
val text = searchTrimmed()
|
||||
if (text.isEmpty() || searchShowingSimplexLink.value) return
|
||||
val name = connectNameCandidate.value
|
||||
confirmDirectorySearch { withApi { onlineSearch.search(text, name) } }
|
||||
}
|
||||
|
||||
val sectionModifier = Modifier.fillMaxWidth()
|
||||
val deletedContactTypes = listOf(ContactType.CHAT_DELETED)
|
||||
val deletedChats by remember(chatModel.chats.value, deletedContactTypes) {
|
||||
@@ -395,14 +356,14 @@ private fun ModalData.NewChatSheetLayout(
|
||||
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
|
||||
connectNameCandidate = connectNameCandidate,
|
||||
close = close,
|
||||
onSearch = ::runOnlineSearch,
|
||||
onSearch = ::runDirectorySearch,
|
||||
)
|
||||
val candidate = connectNameCandidate.value
|
||||
if (candidate != null && onlineSearch.names.isEmpty()) {
|
||||
if (candidate != null) {
|
||||
Divider()
|
||||
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = close)
|
||||
}
|
||||
if (searchTrimmed().isNotEmpty() && !searchShowingSimplexLink.value) {
|
||||
if (searchTrimmed().isNotEmpty() && !searchShowingSimplexLink.value && !onlineSearch.showResults) {
|
||||
Divider()
|
||||
SearchInDirectoryRow(onSearch = ::runDirectorySearch)
|
||||
}
|
||||
@@ -431,7 +392,7 @@ private fun ModalData.NewChatSheetLayout(
|
||||
}
|
||||
ContactListNavLinkView(chat, nextChatSelected, showDeletedChatIcon = true)
|
||||
}
|
||||
onlineSearchSections(rows)
|
||||
onlineSearchSection(rows)
|
||||
if (appPlatform.isAndroid) {
|
||||
item {
|
||||
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.statusBars))
|
||||
@@ -493,14 +454,14 @@ private fun ModalData.NewChatSheetLayout(
|
||||
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
|
||||
connectNameCandidate = connectNameCandidate,
|
||||
close = close,
|
||||
onSearch = ::runOnlineSearch,
|
||||
onSearch = ::runDirectorySearch,
|
||||
)
|
||||
val candidate = connectNameCandidate.value
|
||||
if (candidate != null && onlineSearch.names.isEmpty()) {
|
||||
if (candidate != null) {
|
||||
Divider()
|
||||
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = close)
|
||||
}
|
||||
if (searchTrimmed().isNotEmpty() && !searchShowingSimplexLink.value) {
|
||||
if (searchTrimmed().isNotEmpty() && !searchShowingSimplexLink.value && !onlineSearch.showResults) {
|
||||
Divider()
|
||||
SearchInDirectoryRow(onSearch = ::runDirectorySearch)
|
||||
}
|
||||
@@ -527,7 +488,7 @@ private fun ModalData.NewChatSheetLayout(
|
||||
}
|
||||
ContactListNavLinkView(chat, nextChatSelected, showDeletedChatIcon = true)
|
||||
}
|
||||
onlineSearchSections(rows)
|
||||
onlineSearchSection(rows)
|
||||
if (appPlatform.isAndroid) {
|
||||
item {
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
|
||||
+5
@@ -69,6 +69,11 @@ fun PrivacySettingsView(
|
||||
chatModel.controller.appPrefs.privacyLinkPreviews,
|
||||
onChange = { _ -> chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.set(false) } // to avoid showing alert to current users, show alert in v6.5
|
||||
)
|
||||
SettingsPreferenceItem(
|
||||
painterResource(MR.images.ic_search),
|
||||
stringResource(MR.strings.directory_search),
|
||||
chatModel.controller.appPrefs.privacyDirectorySearch
|
||||
)
|
||||
SettingsPreferenceItem(
|
||||
painterResource(MR.images.ic_link),
|
||||
stringResource(MR.strings.sanitize_links_toggle),
|
||||
|
||||
@@ -515,16 +515,16 @@
|
||||
<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="enable_directory_search_question">Enable Directory search?</string>
|
||||
<string name="enable_directory_search_alert_desc">Search text is sent to SimpleX Directory, each search separately and without your profile, so it can\'t build your search history.</string>
|
||||
<string name="enable_verb">Enable</string>
|
||||
<string name="directory_search_n_members">%1$d members</string>
|
||||
<string name="directory_search_show_more">Show more</string>
|
||||
<string name="directory_search_already_joining">Already joining</string>
|
||||
<string name="directory_search_load_more">Load more</string>
|
||||
<string name="directory_search_load_more_n_left">Load more · %1$d left</string>
|
||||
<string name="directory_search_failed_retry">Search failed, tap to retry</string>
|
||||
<string name="directory_search_page_in_chats">All results on this page are already in your chats</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>
|
||||
@@ -1500,6 +1500,7 @@
|
||||
<string name="app_will_ask_to_confirm_unknown_file_servers">The app will ask to confirm downloads from unknown file servers (except .onion or when SOCKS proxy is enabled).</string>
|
||||
<string name="without_tor_or_vpn_ip_address_will_be_visible_to_file_servers">Without Tor or VPN, your IP address will be visible to file servers.</string>
|
||||
<string name="send_link_previews">Send link previews</string>
|
||||
<string name="directory_search">Directory search</string>
|
||||
<string name="sanitize_links_toggle">Remove link tracking</string>
|
||||
<string name="privacy_show_last_messages">Show last messages</string>
|
||||
<string name="privacy_message_draft">Message draft</string>
|
||||
|
||||
Reference in new issue
Block a user