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:
spacedandy-agents-simplex committed 2026-10-21 02:29:57 +00:00
1 parent 00fa486aae
commit dc1b687e3e
10 files changed
+237 -375

No files matched your search

@@ -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
}
@@ -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"
@@ -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,
@@ -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()
}
@@ -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()
@@ -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()
@@ -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(
@@ -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))
@@ -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>