core, ui: resolve untyped domains (#7198)

* core: resolve untyped domains

* CPError

* only add domain when it has link of correct type

* resolve first

* handle errors

* remove CPError

* update UI types

* remove unused name

Co-authored-by: Evgeny <evgeny@poberezkin.com>

* refactor connection plan

* kotlin: show domain and alternative chat, haskell tests for dual domains

* view/tests

* update kotlin

* dual domains accounting for business chats

* refactor, fix

* fix kotlin

* remove comment

* search

* add resolve mode

* local resolution

* refactor, bot types

* search both contact and channel by name

* fix

* fix searching business chats by name

* fix ui

* ios

* fix ios

* fix icon

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-07-06 08:50:03 +01:00
committed by GitHub
co-authored by Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
parent ff58dbd6df
commit 6150b35a2f
34 changed files with 934 additions and 253 deletions
@@ -4887,7 +4887,13 @@ enum class SimplexLinkType(val linkType: String) {
data class SimplexNameInfo(
val nameType: SimplexNameType,
val nameDomain: SimplexDomain
)
) {
// mirrors backend shortNameInfoStr: "#name" for a simplex public group, else prefix + full domain
val shortStr: String get() = when {
nameType == SimplexNameType.publicGroup && nameDomain.nameTLD == SimplexTLD.simplex && nameDomain.subDomain.isEmpty() -> "#" + nameDomain.domain
else -> (if (nameType == SimplexNameType.publicGroup) "#" else "@") + nameDomain.fullDomainName
}
}
@Serializable
data class SimplexDomain(
@@ -1517,10 +1517,12 @@ object ChatController {
return null
}
suspend fun apiConnectPlan(rh: Long?, connLink: String, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState<Boolean>): Pair<CreatedConnLink, ConnectionPlan>? {
suspend fun apiConnectPlan(rh: Long?, connLink: String, resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState<Boolean>): ConnectionPlanResult? {
val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null }
val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink, linkOwnerSig), inProgress = inProgress)
if (r is API.Result && r.res is CR.CRConnectionPlan) return r.res.connLink to r.res.connectionPlan
val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink, resolveMode, linkOwnerSig), inProgress = inProgress)
if (r is API.Result && r.res is CR.CRConnectionPlan) return ConnectionPlanResult(r.res.connLink, r.res.planSimplexName, r.res.otherSimplexName, r.res.connectionPlan)
// a PRMNever (typing) search that matches nothing locally is not an error to surface
if (r is API.Error && r.err is ChatError.ChatErrorChat && r.err.errorType is ChatErrorType.NotResolvedLocally) return null
if (inProgress.value && r != null) apiConnectResponseAlert(r)
return null
}
@@ -3861,7 +3863,7 @@ sealed class CC {
class APIAddContact(val userId: Long, val incognito: Boolean): CC()
class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC()
class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC()
class APIConnectPlan(val userId: Long, val connLink: String, val linkOwnerSig: LinkOwnerSig? = null): CC()
class APIConnectPlan(val userId: Long, val connLink: String, val resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, val linkOwnerSig: LinkOwnerSig? = null): CC()
class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData, val verifiedDomain: SimplexDomain? = null): CC()
class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData, val verifiedDomain: SimplexDomain? = null): CC()
class APIChangePreparedContactUser(val contactId: Long, val newUserId: Long): CC()
@@ -4070,8 +4072,9 @@ sealed class CC {
is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}"
is ApiChangeConnectionUser -> "/_set conn user :$connId $userId"
is APIConnectPlan -> {
val resolveStr = if (resolveMode != PlanResolveMode.PRMUnknown) " resolve=${resolveMode.cmdString}" else ""
val sigStr = if (linkOwnerSig != null) " sig=${json.encodeToString(linkOwnerSig)}" else ""
"/_connect plan $userId $connLink$sigStr"
"/_connect plan $userId $connLink$resolveStr$sigStr"
}
is APIPrepareContact -> "/_prepare contact $userId ${connLink.cmdString}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(contactShortLinkData)}"
is APIPrepareGroup -> "/_prepare group $userId ${connLink.cmdString} direct=${onOff(directLink)}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(groupShortLinkData)}"
@@ -6513,7 +6516,7 @@ sealed class CR {
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connLinkInvitation: CreatedConnLink, val connection: PendingContactConnection): CR()
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR()
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink, val connectionPlan: ConnectionPlan): CR()
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink, val planSimplexName: SimplexNameInfo? = null, val otherSimplexName: SimplexNameInfo? = null, val connectionPlan: ConnectionPlan): CR()
@Serializable @SerialName("newPreparedChat") class NewPreparedChat(val user: UserRef, val chat: Chat): CR()
@Serializable @SerialName("contactUserChanged") class ContactUserChanged(val user: UserRef, val fromContact: Contact, val newUser: UserRef, val toContact: Contact): CR()
@Serializable @SerialName("groupUserChanged") class GroupUserChanged(val user: UserRef, val fromGroup: GroupInfo, val newUser: UserRef, val toGroup: GroupInfo): CR()
@@ -7103,6 +7106,23 @@ sealed class SimplexDomainError {
@Serializable @SerialName("unknownDomain") object UnknownDomain : SimplexDomainError()
}
data class ConnectionPlanResult(
val connLink: CreatedConnLink,
val planSimplexName: SimplexNameInfo?,
val otherSimplexName: SimplexNameInfo?,
val connectionPlan: ConnectionPlan,
)
// APIConnectPlan resolution scope; PRMNever is local-store-only (no network), used for per-keystroke name search
enum class PlanResolveMode {
PRMAllGroups, PRMUnknown, PRMNever;
val cmdString: String get() = when (this) {
PRMAllGroups -> "allGroups"
PRMUnknown -> "unknown"
PRMNever -> "never"
}
}
@Serializable
sealed class ConnectionPlan {
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
@@ -7121,7 +7141,7 @@ sealed class InvitationLinkPlan {
@Serializable
sealed class ContactAddressPlan {
@Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val verifiedDomain: SimplexDomain? = null): ContactAddressPlan()
@Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null): ContactAddressPlan()
@Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan()
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan()
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan()
@@ -7131,7 +7151,7 @@ sealed class ContactAddressPlan {
@Serializable
sealed class GroupLinkPlan {
@Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val verifiedDomain: SimplexDomain? = null): GroupLinkPlan()
@Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null): GroupLinkPlan()
@Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan()
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan()
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan()
@@ -7441,6 +7461,7 @@ sealed class ChatErrorType {
is ConnectionPlanChatError -> "connectionPlan"
is InvalidConnReq -> "invalidConnReq"
is SimplexDomainNotReady -> "simplexDomainNotReady"
is NotResolvedLocally -> "notResolvedLocally"
is UnsupportedConnReq -> "unsupportedConnReq"
is InvalidChatMessage -> "invalidChatMessage"
is ConnReqMessageProhibited -> "connReqMessageProhibited"
@@ -7524,6 +7545,7 @@ sealed class ChatErrorType {
@Serializable @SerialName("connectionPlan") class ConnectionPlanChatError(val connectionPlan: ConnectionPlan): ChatErrorType()
@Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType()
@Serializable @SerialName("simplexDomainNotReady") class SimplexDomainNotReady(val simplexDomain: SimplexDomain, val simplexDomainError: SimplexDomainError): ChatErrorType()
@Serializable @SerialName("notResolvedLocally") object NotResolvedLocally: ChatErrorType()
@Serializable @SerialName("unsupportedConnReq") object UnsupportedConnReq: ChatErrorType()
@Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType()
@Serializable @SerialName("connReqMessageProhibited") object ConnReqMessageProhibited: ChatErrorType()
@@ -49,6 +49,7 @@ import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.seconds
@@ -745,7 +746,7 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
}
@Composable
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>) {
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<Set<String>>, connectNameCandidate: MutableState<String?>) {
Box {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val focusRequester = remember { FocusRequester() }
@@ -763,6 +764,8 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
searchText = searchText,
enabled = !remember { searchShowingSimplexLink }.value,
trailingContent = null,
// the clear button must line up with the filter icon it replaces, so no reduction here
reducedCloseButtonPadding = 0.dp,
) {
searchText.value = searchText.value.copy(it)
}
@@ -791,30 +794,35 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
LaunchedEffect(Unit) {
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
when (val target = strConnectTarget(it.trim())) {
is ConnectTarget.Link -> {
hideKeyboard(view)
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(target.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
}
is ConnectTarget.Name -> {
// A name lookup means "take me to this contact": open the chat if
// it's already known (visible prompt), unlike a pasted link which
// filters the list. So no filterKnownContact here.
hideKeyboard(view)
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
target.text,
close = null,
cleanup = { searchText.value = TextFieldValue() },
)
.collectLatest {
val target = strConnectTarget(it.trim())
if (target is ConnectTarget.Link) {
hideKeyboard(view)
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = emptySet()
connectNameCandidate.value = null
connect(target.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
} else {
val candidate = nameSearchCandidate(it.trim())
connectNameCandidate.value = candidate
// clear the previous match immediately so the list falls back to text search during the debounce,
// instead of showing a stale filtered chat while the new search runs
searchChatFilteredBySimplexLink.value = emptySet()
if (candidate != null) {
// resolve the name locally on each keystroke, debounced; collectLatest cancels the in-flight
// search when the next keystroke arrives. A bare name can be a contact or a channel, so search
// both and filter every known chat found; drop the row only when both types are already known.
delay(NAME_SEARCH_DEBOUNCE_MS)
val rhId = chatModel.remoteHostId()
val inProgress = mutableStateOf(false) // background search: no spinner, no error alerts
val targets = if (candidate.startsWith("@") || candidate.startsWith("#")) listOf(candidate) else listOf("@$candidate", "#$candidate")
val ids = targets.mapNotNull { name ->
knownChatId(rhId, chatModel.controller.apiConnectPlan(rhId, name, PlanResolveMode.PRMNever, inProgress = inProgress))
}
}
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
searchChatFilteredBySimplexLink.value = ids.toSet()
if (ids.size == targets.size) connectNameCandidate.value = null
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
} else {
@@ -826,7 +834,7 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
}
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
searchChatFilteredBySimplexLink.value = emptySet()
}
}
}
@@ -837,13 +845,13 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
}
}
private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<String?>, cleanup: (() -> Unit)?) {
private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<Set<String>>, cleanup: (() -> Unit)?) {
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
link,
filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id },
filterKnownGroup = { searchChatFilteredBySimplexLink.value = it.id },
filterKnownContact = { searchChatFilteredBySimplexLink.value = setOf(it.id) },
filterKnownGroup = { searchChatFilteredBySimplexLink.value = setOf(it.id) },
close = null,
cleanup = cleanup,
)
@@ -930,7 +938,8 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
// which is related to [derivedStateOf]. Using safe alternative instead
// val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<Set<String>>(emptySet()) }
val connectNameCandidate = remember { mutableStateOf<String?>(null) }
val chats = filteredChats(searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList(), activeFilter.value)
val topPaddingToContent = topPaddingToContent(false)
val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent
@@ -963,13 +972,23 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
if (oneHandUI.value) {
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) {
Divider()
TagsView(searchText)
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
// bottom toolbar: search bar below, so on desktop the connect row goes below the tags
TagsOrConnectByName(searchText, connectNameCandidate) { candidate ->
TagsView(searchText)
Divider()
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
}
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}
} else {
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
TagsView(searchText)
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate)
// top toolbar: search bar above, so on desktop the connect row goes above the tags
TagsOrConnectByName(searchText, connectNameCandidate) { candidate ->
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
Divider()
TagsView(searchText)
}
Divider()
}
}
@@ -1017,6 +1036,105 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
}
}
// Default top-level part used to complete a bare name typed in the search field (search field only;
// the message parser and the wire format are unchanged).
private const val DEFAULT_NAME_TLD = "testing"
// Shortest name that offers the button, so it is discoverable but does not flash on a single letter.
private const val MIN_NAME_LENGTH = 2
// Wait this long after the last keystroke before the local name search runs.
internal const val NAME_SEARCH_DEBOUNCE_MS = 300L
private val nameLabelRegex = Regex("[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*")
private fun isNameLabel(s: String): Boolean = s.length in 1..63 && nameLabelRegex.matches(s)
// On-device candidate for connecting by SimpleX name: the string sent to the core to resolve it.
// Mirrors the domain grammar (nameLabelP/mkDomain in SimplexName.hs): an optional @/# prefix, then
// dot-separated ASCII labels; a dotless word is completed with the default top-level part. Returns
// the string to send (keeping @/# so the type is preserved), or null when the text is not a name.
internal fun nameSearchCandidate(str: String): String? {
val text = str.trim()
val prefix = text.firstOrNull()?.takeIf { it == '@' || it == '#' }
val core = if (prefix != null) text.substring(1) else text
val labels = core.split(".")
if (core.isEmpty() || labels.any { !isNameLabel(it) }) return null
return when {
labels.size > 1 -> text // already has a top-level part
core.length >= MIN_NAME_LENGTH -> "${prefix ?: ""}$core.$DEFAULT_NAME_TLD"
else -> null
}
}
// The chat id a local (PRMNever) search resolved to — a contact, a business, or a channel — or null on a miss.
// The core returns the correct type for @ vs # (getContactToConnect / type-filtered getGroupToConnect), so no
// client-side type check is needed.
internal suspend fun knownChatId(rhId: Long?, result: ConnectionPlanResult?): String? = when (val plan = result?.connectionPlan) {
is ConnectionPlan.ContactAddress -> (plan.contactAddressPlan as? ContactAddressPlan.Known)?.contact?.let { contact ->
// a name-resolved chat may be prepared in the store but not yet listed, so add it (as the tap path does)
if (chatModel.getContactChat(contact.contactId) == null) {
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Direct(contact), chatItems = emptyList()))
}
contact.id
}
is ConnectionPlan.GroupLink -> (when (val g = plan.groupLinkPlan) {
is GroupLinkPlan.Known -> g.groupInfo
is GroupLinkPlan.OwnLink -> g.groupInfo
else -> null
})?.let { gInfo ->
if (chatModel.getGroupChat(gInfo.groupId) == null) {
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Group(gInfo, groupChatScope = null), chatItems = emptyList()))
}
gInfo.id
}
else -> null
}
// The list tags and the connect-by-name row share one slot. When there is no name, the tags show; on
// mobile the row replaces the tags while shown. On desktop both show, arranged by the caller (which
// knows whether the search bar is above or below), passed as desktopView.
@Composable
private fun TagsOrConnectByName(
searchText: MutableState<TextFieldValue>,
connectNameCandidate: MutableState<String?>,
desktopView: @Composable (candidate: String) -> Unit,
) {
val candidate = connectNameCandidate.value
when {
candidate == null -> TagsView(searchText)
!appPlatform.isDesktop -> ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
else -> desktopView(candidate)
}
}
@Composable
internal fun ConnectByNameRow(name: String, searchText: MutableState<TextFieldValue>, connectNameCandidate: MutableState<String?>, close: (() -> Unit)?) {
val view = LocalMultiplatformView()
Row(
Modifier
.fillMaxWidth()
.clickable {
hideKeyboard(view)
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
name,
close = close,
cleanup = {
searchText.value = TextFieldValue()
connectNameCandidate.value = null
},
)
}
}
.padding(vertical = DEFAULT_PADDING_HALF),
verticalAlignment = Alignment.CenterVertically
) {
// icon and text aligned with the search bar's icon and text (same paddings and icon size)
val icon = if (name.startsWith("@")) MR.images.ic_at else MR.images.ic_tag
Icon(painterResource(icon), null, Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.primary)
Text(String.format(generalGetString(MR.strings.connect_plan_connect_to_name), name), color = MaterialTheme.colors.primary)
}
}
@Composable
private fun NoChatsView(searchText: MutableState<TextFieldValue>) {
val activeFilter = remember { chatModel.activeChatTagFilter }.value
@@ -1324,14 +1442,14 @@ fun ItemPresetFilterAction(
fun filteredChats(
searchShowingSimplexLink: State<Boolean>,
searchChatFilteredBySimplexLink: State<String?>,
searchChatFilteredBySimplexLink: State<Set<String>>,
searchText: String,
chats: List<Chat>,
activeFilter: ActiveFilter? = null,
): List<Chat> {
val linkChatId = searchChatFilteredBySimplexLink.value
return if (linkChatId != null) {
chats.filter { it.id == linkChatId }
val linkChatIds = searchChatFilteredBySimplexLink.value
return if (linkChatIds.isNotEmpty()) {
chats.filter { it.id in linkChatIds }
} else {
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
if (s.isEmpty())
@@ -197,7 +197,7 @@ private fun ShareList(
val chats by remember(search) {
derivedStateOf {
val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready && it.chatInfo.sendMsgEnabled && !(chatModel.sharedContent.value is SharedContent.ChatLink && it.chatInfo is ChatInfo.Local) }.sortedByDescending { it.chatInfo is ChatInfo.Local }
filteredChats(mutableStateOf(false), mutableStateOf(null), search, sorted)
filteredChats(mutableStateOf(false), mutableStateOf<Set<String>>(emptySet()), search, sorted)
}
}
val topPaddingToContent = topPaddingToContent(false)
@@ -291,10 +291,13 @@ class AlertManager {
profileFullName: String,
profileImage: @Composable () -> Unit,
profileBadge: LocalBadge? = null,
nameCaption: String? = null,
subtitle: String? = null,
information: String? = null,
confirmText: String? = generalGetString(MR.strings.connect_plan_open_chat),
onConfirm: (() -> Unit)? = null,
connectOtherButton: String? = null,
onConnectOther: (() -> Unit)? = null,
dismissText: String = generalGetString(MR.strings.cancel_verb),
onDismiss: (() -> Unit)? = null,
) {
@@ -337,6 +340,17 @@ class AlertManager {
modifier = Modifier.fillMaxWidth()
)
if (nameCaption != null) {
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
Text(
nameCaption,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.secondary,
maxLines = 1,
modifier = Modifier.fillMaxWidth()
)
}
if (profileFullName.isNotEmpty() && profileFullName != profileName) {
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
Text(
@@ -388,6 +402,14 @@ class AlertManager {
Text(confirmText)
}
}
if (connectOtherButton != null && onConnectOther != null) {
TextButton(onClick = {
onConnectOther.invoke()
hideAlert()
}) {
Text(connectOtherButton)
}
}
TextButton(onClick = {
onDismiss?.invoke()
hideAlert()
@@ -36,7 +36,7 @@ fun SearchTextField(
placeholder: String = stringResource(MR.strings.search_verb),
enabled: Boolean = true,
trailingContent: @Composable (() -> Unit)? = null,
reducedCloseButtonPadding: Dp = 0.dp,
reducedCloseButtonPadding: Dp = 8.dp,
onValueChange: (String) -> Unit
) {
val focusRequester = remember { FocusRequester() }
@@ -116,7 +116,7 @@ fun SearchTextField(
trailingIcon = if (searchText.value.text.isNotEmpty() || trailingContent != null) {{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.offset(x = 8.dp)
modifier = Modifier.offset(x = reducedCloseButtonPadding)
) {
if (searchText.value.text.isNotEmpty()) {
IconButton({
@@ -74,13 +74,19 @@ private suspend fun planAndConnectTask(
cleanup?.invoke()
completable.complete(!completable.isActive)
}
val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink, linkOwnerSig, inProgress = inProgress)
val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink, linkOwnerSig = linkOwnerSig, inProgress = inProgress)
connectProgressManager.stopConnectProgress()
if (!inProgress.value) { return completable }
if (result != null) {
val (connectionLink, connectionPlan) = result
val (connectionLink, planSimplexName, otherSimplexName, connectionPlan) = result
val target = strConnectTarget(shortOrFullLink.trim())
val linkText = if (target is ConnectTarget.Link) "<br><br><u>${target.linkText}</u>" else ""
// the name can also resolve to the other kind; its type picks the verb, its short form the label and target
val connectOtherLink = otherSimplexName?.shortStr
val connectOtherButton = otherSimplexName?.let {
val label = if (it.nameType == SimplexNameType.publicGroup) MR.strings.connect_plan_join_name else MR.strings.connect_plan_connect_to_name
generalGetString(label).format(it.shortStr)
}
when (connectionPlan) {
is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) {
is InvitationLinkPlan.Ok ->
@@ -154,7 +160,9 @@ private suspend fun planAndConnectTask(
connectionLink,
connectionPlan.contactAddressPlan.contactSLinkData_,
ownerVerification = connectionPlan.contactAddressPlan.ownerVerification,
verifiedDomain = connectionPlan.contactAddressPlan.verifiedDomain,
planSimplexName = planSimplexName,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
close,
cleanup
)
@@ -167,6 +175,8 @@ private suspend fun planAndConnectTask(
connectDestructive = false,
cleanup,
ownerVerification = connectionPlan.contactAddressPlan.ownerVerification,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
)
}
ContactAddressPlan.OwnLink -> {
@@ -177,6 +187,8 @@ private suspend fun planAndConnectTask(
text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address) + linkText,
connectDestructive = true,
cleanup = cleanup,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
)
}
ContactAddressPlan.ConnectingConfirmReconnect -> {
@@ -187,6 +199,8 @@ private suspend fun planAndConnectTask(
text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address) + linkText,
connectDestructive = true,
cleanup = cleanup,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
)
}
is ContactAddressPlan.ConnectingProhibit -> {
@@ -195,7 +209,7 @@ private suspend fun planAndConnectTask(
if (filterKnownContact != null) {
filterKnownContact(contact)
} else {
showOpenKnownContactAlert(chatModel, rhId, close, contact)
showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
cleanup()
}
}
@@ -211,15 +225,24 @@ private suspend fun planAndConnectTask(
if (filterKnownContact != null) {
filterKnownContact(contact)
} else {
showOpenKnownContactAlert(chatModel, rhId, close, contact)
showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
cleanup()
}
}
is ContactAddressPlan.ContactViaAddress -> {
Log.d(TAG, "planAndConnect, .ContactAddress, .ContactViaAddress")
val contact = connectionPlan.contactAddressPlan.contact
askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close, openChat = false)
cleanup()
// the contact is already prepared in the store, so open the existing chat instead of sending a new
// connection request; surface it in the chat list first if it is not there yet (as for Known above)
if (chatModel.getContactChat(contact.contactId) == null) {
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Direct(contact), chatItems = emptyList()))
}
if (filterKnownContact != null) {
filterKnownContact(contact)
} else {
showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
cleanup()
}
}
}
is ConnectionPlan.GroupLink -> when (connectionPlan.groupLinkPlan) {
@@ -232,7 +255,9 @@ private suspend fun planAndConnectTask(
connectionPlan.groupLinkPlan.groupSLinkInfo_,
connectionPlan.groupLinkPlan.groupSLinkData_,
ownerVerification = connectionPlan.groupLinkPlan.ownerVerification,
verifiedDomain = connectionPlan.groupLinkPlan.verifiedDomain,
planSimplexName = planSimplexName,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
close,
cleanup
)
@@ -245,6 +270,8 @@ private suspend fun planAndConnectTask(
connectDestructive = false,
cleanup = cleanup,
ownerVerification = connectionPlan.groupLinkPlan.ownerVerification,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
)
}
is GroupLinkPlan.OwnLink -> {
@@ -253,7 +280,7 @@ private suspend fun planAndConnectTask(
if (filterKnownGroup != null) {
filterKnownGroup(groupInfo)
} else {
ownGroupLinkConfirmConnect(chatModel, rhId, connectionLink, linkText, connectionPlan, groupInfo, close, cleanup)
ownGroupLinkConfirmConnect(chatModel, rhId, connectionLink, linkText, connectionPlan, groupInfo, close, cleanup, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
}
}
GroupLinkPlan.ConnectingConfirmReconnect -> {
@@ -264,6 +291,8 @@ private suspend fun planAndConnectTask(
text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + linkText,
connectDestructive = true,
cleanup = cleanup,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
)
}
is GroupLinkPlan.ConnectingProhibit -> {
@@ -301,7 +330,7 @@ private suspend fun planAndConnectTask(
if (filterKnownGroup != null) {
filterKnownGroup(groupInfo)
} else {
showOpenKnownGroupAlert(chatModel, rhId, close, groupInfo)
showOpenKnownGroupAlert(chatModel, rhId, close, groupInfo, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
cleanup()
}
}
@@ -427,6 +456,8 @@ fun askCurrentOrIncognitoProfileAlert(
connectDestructive: Boolean,
cleanup: (() -> Unit)?,
ownerVerification: OwnerVerification? = null,
connectOtherButton: String? = null,
connectOtherLink: String? = null,
) {
val fullText = listOfNotNull(text, ownerVerificationMessage(ownerVerification)).joinToString("\n\n").ifEmpty { null }
AlertManager.privacySensitive.showAlertDialogButtonsColumn(
@@ -451,6 +482,14 @@ fun askCurrentOrIncognitoProfileAlert(
}) {
Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor)
}
if (connectOtherButton != null && connectOtherLink != null) {
SectionItemView({
AlertManager.privacySensitive.hideAlert()
withBGApi { planAndConnect(rhId, connectOtherLink, close = close, cleanup = cleanup) }
}) {
Text(connectOtherButton, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.privacySensitive.hideAlert()
cleanup?.invoke()
@@ -473,7 +512,11 @@ fun openChat_(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, chat: Cha
val alertProfileImageSize = 138.dp
private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, contact: Contact) {
// For alerts that show the name inline (not as a profile with an avatar): "Alice" -> "Alice (@alice.testing)".
private fun nameWithDomain(name: String, planSimplexName: SimplexNameInfo?): String =
name + (planSimplexName?.let { " (${it.shortStr})" } ?: "")
private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, contact: Contact, planSimplexName: SimplexNameInfo? = null, connectOtherButton: String? = null, connectOtherLink: String? = null) {
AlertManager.privacySensitive.showOpenChatAlert(
profileName = contact.profile.displayName,
profileFullName = contact.profile.fullName,
@@ -486,10 +529,13 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close:
},
// the alert shows the badge inline, so it skips the long-expired (ExpiredOld) badge here too
profileBadge = if (contact.active && contact.profile.localBadge?.status != BadgeStatus.ExpiredOld) contact.profile.localBadge else null,
nameCaption = planSimplexName?.shortStr,
confirmText = generalGetString(if (contact.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat),
onConfirm = {
openKnownContact(chatModel, rhId, close, contact)
},
connectOtherButton = connectOtherButton,
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close) } } },
onDismiss = null
)
}
@@ -513,11 +559,14 @@ fun ownGroupLinkConfirmConnect(
groupInfo: GroupInfo,
close: (() -> Unit)?,
cleanup: (() -> Unit)?,
planSimplexName: SimplexNameInfo? = null,
connectOtherButton: String? = null,
connectOtherLink: String? = null,
) {
if (groupInfo.useRelays) {
AlertManager.privacySensitive.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel),
text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel_vName), groupInfo.displayName),
text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel_vName), nameWithDomain(groupInfo.displayName, planSimplexName)),
buttons = {
Column {
SectionItemView({
@@ -527,6 +576,14 @@ fun ownGroupLinkConfirmConnect(
}) {
Text(generalGetString(MR.strings.connect_plan_open_channel), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
if (connectOtherButton != null && connectOtherLink != null) {
SectionItemView({
AlertManager.privacySensitive.hideAlert()
withBGApi { planAndConnect(rhId, connectOtherLink, close = close, cleanup = cleanup) }
}) {
Text(connectOtherButton, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.privacySensitive.hideAlert()
cleanup?.invoke()
@@ -585,7 +642,7 @@ fun ownGroupLinkConfirmConnect(
}
}
private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo) {
private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo, planSimplexName: SimplexNameInfo? = null, connectOtherButton: String? = null, connectOtherLink: String? = null) {
val subscriberCount = if (groupInfo.useRelays) groupInfo.groupSummary.publicMemberCount?.let { subscriberCountStr(it) } else null
AlertManager.privacySensitive.showOpenChatAlert(
profileName = groupInfo.groupProfile.displayName,
@@ -597,6 +654,7 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: ((
icon = groupInfo.chatIconName
)
},
nameCaption = planSimplexName?.shortStr,
subtitle = subscriberCount,
confirmText = generalGetString(
if (groupInfo.useRelays) {
@@ -610,6 +668,8 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: ((
onConfirm = {
openKnownGroup(chatModel, rhId, close, groupInfo)
},
connectOtherButton = connectOtherButton,
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close) } } },
onDismiss = null
)
}
@@ -629,7 +689,9 @@ fun showPrepareContactAlert(
connectionLink: CreatedConnLink,
contactShortLinkData: ContactShortLinkData,
ownerVerification: OwnerVerification? = null,
verifiedDomain: SimplexDomain? = null,
planSimplexName: SimplexNameInfo? = null,
connectOtherButton: String? = null,
connectOtherLink: String? = null,
close: (() -> Unit)?,
cleanup: (() -> Unit)?
) {
@@ -647,13 +709,14 @@ fun showPrepareContactAlert(
)
},
profileBadge = if (contactShortLinkData.localBadge?.status == BadgeStatus.ExpiredOld) null else contactShortLinkData.localBadge,
nameCaption = planSimplexName?.shortStr,
information = ownerVerificationMessage(ownerVerification),
confirmText = generalGetString(MR.strings.connect_plan_open_new_chat),
onConfirm = {
AlertManager.privacySensitive.hideAlert()
ModalManager.closeAllModalsEverywhere()
withBGApi {
val chat = chatModel.controller.apiPrepareContact(rhId, connectionLink, contactShortLinkData, verifiedDomain)
val chat = chatModel.controller.apiPrepareContact(rhId, connectionLink, contactShortLinkData, planSimplexName?.nameDomain)
if (chat != null) {
withContext(Dispatchers.Main) {
ChatController.chatModel.chatsContext.addChat(chat)
@@ -663,6 +726,8 @@ fun showPrepareContactAlert(
cleanup?.invoke()
}
},
connectOtherButton = connectOtherButton,
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup) } } },
onDismiss = {
cleanup?.invoke()
}
@@ -675,7 +740,9 @@ fun showPrepareGroupAlert(
groupShortLinkInfo: GroupShortLinkInfo?,
groupShortLinkData: GroupShortLinkData,
ownerVerification: OwnerVerification? = null,
verifiedDomain: SimplexDomain? = null,
planSimplexName: SimplexNameInfo? = null,
connectOtherButton: String? = null,
connectOtherLink: String? = null,
close: (() -> Unit)?,
cleanup: (() -> Unit)?
) {
@@ -691,6 +758,7 @@ fun showPrepareGroupAlert(
icon = if (isChannel) MR.images.ic_bigtop_updates_circle_filled else MR.images.ic_supervised_user_circle_filled
)
},
nameCaption = planSimplexName?.shortStr,
subtitle = subscriberCount,
information = ownerVerificationMessage(ownerVerification),
confirmText = generalGetString(if (isChannel) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_new_group),
@@ -698,7 +766,7 @@ fun showPrepareGroupAlert(
AlertManager.privacySensitive.hideAlert()
withBGApi {
val directLink = groupShortLinkInfo?.direct ?: true
val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, directLink = directLink, groupShortLinkData, verifiedDomain)
val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, directLink = directLink, groupShortLinkData, planSimplexName?.nameDomain)
if (chat != null) {
withContext(Dispatchers.Main) {
val relays = groupShortLinkInfo?.groupRelays
@@ -715,6 +783,8 @@ fun showPrepareGroupAlert(
cleanup?.invoke()
}
},
connectOtherButton = connectOtherButton,
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup) } } },
onDismiss = {
cleanup?.invoke()
}
@@ -136,7 +136,8 @@ private fun ModalData.NewChatSheetLayout(
}
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<Set<String>>(emptySet()) }
val connectNameCandidate = remember { mutableStateOf<String?>(null) }
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
val baseContactTypes = remember { listOf(ContactType.CARD, ContactType.CONTACT_WITH_REQUEST, ContactType.REQUEST, ContactType.RECENT) }
val contactTypes by remember(searchText.value.text.isEmpty()) {
@@ -313,8 +314,13 @@ private fun ModalData.NewChatSheetLayout(
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
connectNameCandidate = connectNameCandidate,
close = close,
)
connectNameCandidate.value?.let { candidate ->
Divider()
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = close)
}
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}
}
@@ -399,8 +405,13 @@ private fun ModalData.NewChatSheetLayout(
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
connectNameCandidate = connectNameCandidate,
close = close,
)
connectNameCandidate.value?.let { candidate ->
Divider()
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = close)
}
Divider()
}
}
@@ -466,7 +477,8 @@ private fun ContactsSearchBar(
listState: LazyListState,
searchText: MutableState<TextFieldValue>,
searchShowingSimplexLink: MutableState<Boolean>,
searchChatFilteredBySimplexLink: MutableState<String?>,
searchChatFilteredBySimplexLink: MutableState<Set<String>>,
connectNameCandidate: MutableState<String?>,
close: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
@@ -485,6 +497,8 @@ private fun ContactsSearchBar(
alwaysVisible = true,
searchText = searchText,
trailingContent = null,
// the clear button must line up with the filter icon it replaces, so no reduction here
reducedCloseButtonPadding = 0.dp,
) {
searchText.value = searchText.value.copy(it)
}
@@ -523,34 +537,26 @@ private fun ContactsSearchBar(
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
when (val target = strConnectTarget(it.trim())) {
is ConnectTarget.Link -> {
hideKeyboard(view)
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(
link = target.text,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
cleanup = { searchText.value = TextFieldValue() }
)
}
is ConnectTarget.Name -> {
// A name lookup means "take me to this contact": open the chat if
// it's already known (visible prompt), unlike a pasted link which
// filters the list. So no filterKnownContact here.
hideKeyboard(view)
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
target.text,
close = close,
cleanup = { searchText.value = TextFieldValue() },
)
}
}
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
val target = strConnectTarget(it.trim())
if (target is ConnectTarget.Link) {
hideKeyboard(view)
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = emptySet()
connectNameCandidate.value = null
connect(
link = target.text,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
cleanup = { searchText.value = TextFieldValue() }
)
} else {
// A name is resolved only when its "Connect to …" row is tapped, not on every keystroke. The
// simplex-name filter is chat-list only: this contacts/deleted view is a scoped subset, so a
// resolved chat id (channel, business, unlisted or active-only contact) may not be present in it.
val candidate = nameSearchCandidate(it.trim())
connectNameCandidate.value = candidate
if (candidate == null && (!searchShowingSimplexLink.value || it.isEmpty())) {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
} else {
@@ -560,7 +566,7 @@ private fun ContactsSearchBar(
}
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
searchChatFilteredBySimplexLink.value = emptySet()
}
}
}
@@ -587,12 +593,12 @@ private fun ToggleFilterButton() {
}
}
private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<String?>, close: () -> Unit, cleanup: (() -> Unit)?) {
private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<Set<String>>, close: () -> Unit, cleanup: (() -> Unit)?) {
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
link,
filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id },
filterKnownContact = { searchChatFilteredBySimplexLink.value = setOf(it.id) },
close = close,
cleanup = cleanup,
)
@@ -602,15 +608,15 @@ private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<
private fun filteredContactChats(
showUnreadAndFavorites: Boolean,
searchShowingSimplexLink: State<Boolean>,
searchChatFilteredBySimplexLink: State<String?>,
searchChatFilteredBySimplexLink: State<Set<String>>,
searchText: String,
contactChats: List<Chat>
): List<Chat> {
val linkChatId = searchChatFilteredBySimplexLink.value
val linkChatIds = searchChatFilteredBySimplexLink.value
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
return if (linkChatId != null) {
contactChats.filter { it.id == linkChatId }
return if (linkChatIds.isNotEmpty()) {
contactChats.filter { it.id in linkChatIds }
} else {
contactChats.filter { chat ->
filterChat(
@@ -666,7 +672,9 @@ private fun ModalData.DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats
val listState = remember { appBarHandler.listState }
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<Set<String>>(emptySet()) }
// deleted contacts are not connected to by name, so this candidate only stops per-keystroke resolution
val connectNameCandidate = remember { mutableStateOf<String?>(null) }
val showUnreadAndFavorites = remember { appPrefs.showUnreadAndFavorites.state }.value
val allChats by remember(chatModel.chats.value) {
derivedStateOf { filterContactTypes(chatModel.chats.value, listOf(ContactType.CHAT_DELETED)) }
@@ -709,6 +717,7 @@ private fun ModalData.DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
connectNameCandidate = connectNameCandidate,
close = close,
)
} else {
@@ -718,6 +727,7 @@ private fun ModalData.DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
connectNameCandidate = connectNameCandidate,
close = close,
)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
@@ -15,6 +15,8 @@
<string name="connect_via_link_verb">Connect</string>
<string name="connect_via_link_incognito">Connect incognito</string>
<string name="connect_plan_open_chat">Open chat</string>
<string name="connect_plan_join_name">Join channel %s</string>
<string name="connect_plan_connect_to_name">Connect to %s</string>
<string name="connect_plan_open_new_chat">Open new chat</string>
<string name="connect_plan_open_group">Open group</string>
<string name="connect_plan_open_new_group">Open new group</string>