diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt index c3919a5f9a..0b2a97b5a4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt @@ -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, // 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(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, - val directory: List, + val yourChats: List, + val results: List, 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 } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index dd6b1f16fd..dfc70cc13b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -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" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index e9dbb3b157..d11c97a639 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -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, 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 35c054aa5f..a8b11800ab 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 @@ -472,19 +472,12 @@ private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState, 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): 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, 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) { @@ -1240,8 +1175,7 @@ private fun BoxScope.ChatList(searchText: MutableState, 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() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt index 063d8fb42f..8b3057ade0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt @@ -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>(emptyList()) - private set var directory by mutableStateOf>(emptyList()) private set + var moreCount by mutableStateOf(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): OnlineSearchRows { - val nameChatIds = names.mapNotNull { it.localChatId }.toSet() - val nameLinks = names.mapNotNull { it.resolvedLink }.toSet() - fun isAmong(ids: Set, id: ChatId?): Boolean = - id?.let { ids.contains(it) } ?: false + val yourChats = mutableListOf() + val results = mutableListOf() + 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() - for (result in names) { - if (result.localChatId == null) localChatId(rh, result.target)?.let { nameChatIds[result.id] = it } - } - val directoryChatIds = mutableMapOf() + val states = mutableMapOf() 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() - 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() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt index 24cccd90ab..d807105b86 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt @@ -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() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index e55834c8ac..e4e2753a88 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -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 { 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 ): CompletableDeferred { val completable = CompletableDeferred() @@ -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( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index ba2acfa628..45d4d1ccbd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -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)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 1696e5f1fe..a57bcd6c64 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -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), diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 95b53d8cd1..08ddd0caf4 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -515,16 +515,16 @@ Connect with %1$s? Search or paste SimpleX link Search in Directory - Search in Directory? - 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. - Search and don\'t show again + Enable Directory search? + Search text is sent to SimpleX Directory, each search separately and without your profile, so it can\'t build your search history. + Enable %1$d members - Show more + Already joining + Load more + Load more · %1$d left Search failed, tap to retry All results on this page are already in your chats Searching directory… - Names - Directory No selected chat @@ -1500,6 +1500,7 @@ The app will ask to confirm downloads from unknown file servers (except .onion or when SOCKS proxy is enabled). Without Tor or VPN, your IP address will be visible to file servers. Send link previews + Directory search Remove link tracking Show last messages Message draft