From ab61d51afd9e7c551adcf011b09b676046469b12 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 24 Sep 2026 09:09:50 +0200 Subject: [PATCH] review iterations --- apps/ios/Shared/Model/AppAPITypes.swift | 6 -- apps/ios/Shared/Model/ChatModel.swift | 1 - .../Shared/Views/NewChat/NewChatView.swift | 36 ++--------- .../Views/UserSettings/UserAddressView.swift | 1 - .../chat/simplex/common/model/ChatModel.kt | 4 -- .../chat/simplex/common/model/SimpleXAPI.kt | 3 - .../common/views/newchat/ConnectPlan.kt | 61 ++++++------------- .../views/usersettings/UserAddressView.kt | 1 - src/Simplex/Chat/Library/Commands.hs | 58 +++++++----------- src/Simplex/Chat/View.hs | 1 - tests/ChatTests/Names.hs | 11 ---- tests/NameResolver.hs | 1 - 12 files changed, 43 insertions(+), 141 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 63f30b1e62..240f1e2de6 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1461,11 +1461,9 @@ enum ConnectionPlan: Decodable, Hashable { case invitationLink(invitationLinkPlan: InvitationLinkPlan) case contactAddress(contactAddressPlan: ContactAddressPlan, nameRegistration_: NameRegistration?) case groupLink(groupLinkPlan: GroupLinkPlan, nameRegistration_: NameRegistration?) - // the name resolved but there is nothing to connect to, and no local chat claims it case nameNotConnectable(simplexDomain: SimplexDomain, nameRegistration: NameRegistration) case error(chatError: ChatError) - // the registry answer this plan was built from, when the target was a name var nameRegistration: NameRegistration? { switch self { case let .contactAddress(_, reg): reg @@ -1476,8 +1474,6 @@ enum ConnectionPlan: Decodable, Hashable { } } -// What the registry holds for a name. This JSON is the RNAME payload, so it is "type"-tagged on -// every platform, and the unset text fields of the record are not read here. enum NameRegistration: Hashable { // held by someone; expires/graceUntil are absent from an older router, which means "not known", not "live forever" case registered(expires: Int64?, graceUntil: Int64?, reservedReason_: String?) @@ -1487,7 +1483,6 @@ enum NameRegistration: Hashable { // the registry may add reasons after this version, so any other value is just "not registrable" static let reservedCommunity = "community" - // a name past its expiry does not connect: only its owner can renew it until the grace ends func expired(_ now: Int64) -> Bool { if case let .registered(expires, _, _) = self, let expires { expires < now } else { false } } @@ -1528,7 +1523,6 @@ extension NameRegistration: Decodable { } } -// enough to price a name locally: US cents per year, and the shortest label the registry accepts struct NamePricing: Decodable, Hashable { var registrationPrices: [String: Int64] var basePrice: Int64 diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index b344ca2337..48f47b8b63 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -1500,7 +1500,6 @@ enum UIRemoteCtrlSessionState { case connected(remoteCtrl: RemoteCtrlInfo, sessionCode: String) } -// when a SimpleX name was last resolved from the registry, and when that registration runs out struct SimplexNameResolved: Codable { var at: Int64 var expires: Int64? diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 9d88793a54..99217ccec6 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1319,25 +1319,20 @@ private func showOpenKnownGroupAlert( ) } -// The name-lookup answers the registry can give for a name, as the lookup canvas draws them. -// Only an actionable answer becomes an alert; everything else leaves the plan to speak for itself. - private let simplexNamesHowToURL = "https://simplex.domains/#testing" private let nameResolvedDaySeconds: Int64 = 24 * 60 * 60 -private func nameResolvedNow() -> Int64 { Int64(Date.now.timeIntervalSince1970) } +private func nowSeconds() -> Int64 { Int64(Date.now.timeIntervalSince1970) } private func saveNamesResolvedAt(_ m: [String: SimplexNameResolved]) { - // only names looked up in the last week are worth remembering - let now = nameResolvedNow() + let now = nowSeconds() simplexNamesResolvedAtDefault.set(m.filter { now - $0.value.at < 7 * nameResolvedDaySeconds }) } -// a cached answer is stale a day after it was taken, or as soon as the name it described expired func simplexNameResolvedRecently(_ domain: SimplexDomain) -> Bool { guard let r = simplexNamesResolvedAtDefault.get()[domain.fullDomainName] else { return false } - let now = nameResolvedNow() + let now = nowSeconds() if now - r.at >= nameResolvedDaySeconds { return false } return r.expires.map { now < $0 } ?? true } @@ -1345,17 +1340,15 @@ func simplexNameResolvedRecently(_ domain: SimplexDomain) -> Bool { func recordSimplexNameResolved(_ domain: SimplexDomain, _ reg: NameRegistration?) { var m = simplexNamesResolvedAtDefault.get() let expires: Int64? = if case let .registered(expires, _, _) = reg { expires } else { nil } - m[domain.fullDomainName] = SimplexNameResolved(at: nameResolvedNow(), expires: expires) + m[domain.fullDomainName] = SimplexNameResolved(at: nowSeconds(), expires: expires) saveNamesResolvedAt(m) } -// a name registered, claimed or dropped on this device must not keep reading as it did before func forgetSimplexNameResolved(_ fullDomainName: String) { var m = simplexNamesResolvedAtDefault.get() if m.removeValue(forKey: fullDomainName) != nil { saveNamesResolvedAt(m) } } - private func nameDate(_ seconds: Int64) -> String { Date(timeIntervalSince1970: TimeInterval(seconds)).formatted(date: .abbreviated, time: .omitted) } @@ -1372,8 +1365,6 @@ private func showNameNotRegisteredAlert() { ) } -// bands 2, 3 and 4 of the lookup canvas: what the registry said, when the user can act on it. -// Returns false when the answer is not actionable, and the caller shows its usual plan UI. private func showNameRegistrationAlert( domain: SimplexDomain, reg: NameRegistration, @@ -1385,7 +1376,6 @@ private func showNameRegistrationAlert( ) -> Bool { let nameStr = domain.fullDomainName let now = Int64(Date.now.timeIntervalSince1970) - // an extra way back to the chat the user already has, when there is no filtered list behind the alert func alert(_ title: String, _ message: String, action: (title: String, handler: () -> Void)? = nil) { showAlert(title, message: message, actions: { var actions: [UIAlertAction] = [] @@ -1401,7 +1391,6 @@ private func showNameRegistrationAlert( } let openHowTo = { openBrowserAlert(uri: simplexNamesHowToURL) } switch reg { - // 2b / 3b / 4c: expired, and only its owner can renew it until the grace ends case let .registered(expires, graceUntil, _) where reg.expired(now): if isOwn { let message = if let expires, let graceUntil { @@ -1419,7 +1408,6 @@ private func showNameRegistrationAlert( alert(NSLocalizedString("Name expired", comment: "alert title"), message) } return true - // 2c / 3d / 4d: free to register, unless it is shorter than the registry accepts (then 2e) case let .available(pricing) where domain.domain.count >= pricing.minLabelLength: let price = nameCentsPerYear(pricing, domain) if isOwn { @@ -1438,31 +1426,25 @@ private func showNameRegistrationAlert( ) } return true - // 2d: the only reserved reason that says so; every other one reads as "not registered" case _ where reg.reservedForCommunity: alert( NSLocalizedString("Name not registered", comment: "alert title"), String.localizedStringWithFormat(NSLocalizedString("%@ is reserved for community. Please contact SimpleX Chat if you are interested in using it.", comment: "alert message"), nameStr), action: (NSLocalizedString("Connect to SimpleX team", comment: "alert action"), { - // simplexTeamURL targets this same app; route to the in-app connect flow ChatModel.shared.appOpenUrl = simplexTeamURL }) ) return true - // 2e: too short, reserved for another reason, or otherwise not registrable case .reserved, .available: if hasLocalChat || isOwn { return false } alert(NSLocalizedString("Name not registered", comment: "alert title"), NSLocalizedString("This SimpleX name is not registered. Please check the name.", comment: "")) return true - // 2f: registered and live, but the record has no usable link. Only the plan knows that: - // a live registration the plan can connect to has nothing to say. case .registered where notConnectable && !hasLocalChat && !isOwn: alert( NSLocalizedString("No valid link", comment: ""), String.localizedStringWithFormat(NSLocalizedString("The SimpleX name %@ is registered, but it has no valid link.", comment: ""), nameStr) ) return true - // 3a: still leads to the chat you have, nothing to say default: return false } @@ -1502,18 +1484,13 @@ func planAndConnect( func connectTask(_ inProgress: BoxedValue) { Task { - // A name whose cached answer is still fresh is tried against the store first, so a chat the user - // already has does not cost a lookup; a miss falls through to a full resolution, which is what a - // name with no chat gets on every tap. Anything that is not a name resolves as it always did. let nameTarget: SimplexNameInfo? = if case let .name(_, nameInfo) = strConnectTarget(shortOrFullLink) { nameInfo } else { nil } var result: ConnectionPlanResult? = nil if let nameTarget, simplexNameResolvedRecently(nameTarget.nameDomain) { - // a local probe: no error alerts, so a miss falls through silently result = await apiConnectPlan(connLink: shortOrFullLink, resolveMode: .never, linkOwnerSig: linkOwnerSig, inProgress: BoxedValue(false)) } if result == nil && inProgress.boxedValue { result = await apiConnectPlan(connLink: shortOrFullLink, resolveMode: nameTarget != nil ? .all : .unknown, linkOwnerSig: linkOwnerSig, inProgress: inProgress) - // remember when this name was last taken from the registry, and when its registration runs out if let nameTarget, let result { recordSimplexNameResolved(nameTarget.nameDomain, result.connectionPlan.nameRegistration) } @@ -1535,8 +1512,6 @@ func planAndConnect( info.shortStr ) } - // One actionable answer from the registry is one alert, shown over whatever the plan would have. - // With a list to filter the chats found stay behind it; from a message the alert opens them instead. let nameDomain: SimplexDomain? = if case let .nameNotConnectable(simplexDomain, _) = connectionPlan { simplexDomain } else { planSimplexName?.nameDomain } if let nameReg = connectionPlan.nameRegistration, let nameDomain { let knownContact: Contact? = if case let .contactAddress(.known(contact), _) = connectionPlan { contact } else { nil } @@ -1546,7 +1521,6 @@ func planAndConnect( default: false } let handled = await MainActor.run { - // the chat found stays reachable: filtered into the list where there is one, opened from the alert where there is not var openExisting: (() -> Void)? = nil if let contact = knownContact { if ChatModel.shared.getContactChat(contact.contactId) == nil { @@ -1580,7 +1554,6 @@ func planAndConnect( if handled { return } } guard let connectionLink = result.connLink else { - // a name with nothing to connect to has no link; an actionable answer was already alerted above await MainActor.run { if case .nameNotConnectable = connectionPlan { showNameNotRegisteredAlert() } cleanup?() @@ -1589,7 +1562,6 @@ func planAndConnect( } switch connectionPlan { case .nameNotConnectable: - // nothing actionable and nothing to connect to: as today, the name simply did not resolve logger.debug("planAndConnect, .nameNotConnectable") await MainActor.run { showNameNotRegisteredAlert() diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 93c127459c..6ad2667fcd 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -205,7 +205,6 @@ struct UserAddressView: View { save: { simplexDomain in do { let u = try await apiSetUserDomain(simplexDomain) - // a name claimed or dropped here must not keep reading from the answer taken before if let d = chatModel.currentUser?.profile.contactDomain?.domain { forgetSimplexNameResolved(d) } await MainActor.run { chatModel.updateUser(u) } return true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 8d458a49d0..7bbc775556 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -5252,11 +5252,9 @@ enum class SimplexTLD { @SerialName("web") web } -// when a SimpleX name was last resolved from the registry, and when that registration runs out @Serializable data class SimplexNameResolved(val at: Long, val expires: Long? = null) -// What the registry holds for a name - the RNAME payload, "type"-tagged on every platform. @Serializable sealed class NameRegistration { // held by someone; expires/graceUntil are absent from an older router, which means "not known", not "live forever" @@ -5268,7 +5266,6 @@ sealed class NameRegistration { @Serializable @SerialName("available") class Available(val pricing: NamePricing): NameRegistration() @Serializable @SerialName("reserved") class Reserved(val reservedReason: String): NameRegistration() - // a name past its expiry does not connect: only its owner can renew it until the grace ends fun expired(now: Long): Boolean = this is Registered && expires != null && expires < now val reservedForCommunity: Boolean get() = when (this) { @@ -5283,7 +5280,6 @@ sealed class NameRegistration { } } -// enough to price a name locally: US cents per year, and the shortest label the registry accepts @Serializable data class NamePricing( val registrationPrices: Map = emptyMap(), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index d4127dd59c..f6711f7b22 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -123,7 +123,6 @@ class AppPreferences { val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true) val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true) val privacyVerifySimplexNames = mkBoolPreference(SHARED_PREFS_PRIVACY_VERIFY_SIMPLEX_NAMES, false) - // when each SimpleX name was last resolved, so a name with a chat is not re-resolved on every tap val simplexNamesResolvedAt = mkStrPreference(SHARED_PREFS_SIMPLEX_NAMES_RESOLVED_AT, null) val privacyLinkPreviewsShowAlert = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT, true) val privacySanitizeLinks = mkBoolPreference(SHARED_PREFS_PRIVACY_SANITIZE_LINKS, false) @@ -7446,11 +7445,9 @@ sealed class ConnectionPlan { @Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() @Serializable @SerialName("contactAddress") class ContactAddress(val contactAddressPlan: ContactAddressPlan, val nameRegistration_: NameRegistration? = null): ConnectionPlan() @Serializable @SerialName("groupLink") class GroupLink(val groupLinkPlan: GroupLinkPlan, val nameRegistration_: NameRegistration? = null): ConnectionPlan() - // the name resolved but there is nothing to connect to, and no local chat claims it @Serializable @SerialName("nameNotConnectable") class NameNotConnectable(val simplexDomain: SimplexDomain, val nameRegistration: NameRegistration): ConnectionPlan() @Serializable @SerialName("error") class Error(val chatError: ChatError): ConnectionPlan() - // the registry answer this plan was built from, when the target was a name fun nameRegistration(): NameRegistration? = when (this) { is ContactAddress -> nameRegistration_ is GroupLink -> nameRegistration_ diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 7d775cddf5..11d84d727d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -61,8 +61,6 @@ suspend fun planAndConnect( return planAndConnectTask(rhId, shortOrFullLink, linkOwnerSig, close, cleanup, filterKnownContact, filterKnownGroup, inProgress) } -// The registry's answers for a name; only an actionable one becomes an alert. - private fun nameDate(seconds: Long): String = Instant.fromEpochSeconds(seconds).toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() @@ -77,6 +75,8 @@ private const val SIMPLEX_NAMES_HOWTO_URL = "https://simplex.domains/#testing" private const val NAME_RESOLVED_DAY_SECONDS = 24 * 60 * 60L +private fun nowSeconds(): Long = Clock.System.now().epochSeconds + private fun loadNamesResolvedAt(): MutableMap = try { val s = ChatController.appPrefs.simplexNamesResolvedAt.get() ?: return mutableMapOf() @@ -86,8 +86,7 @@ private fun loadNamesResolvedAt(): MutableMap = } private fun saveNamesResolvedAt(m: Map) { - // only names looked up in the last week are worth remembering - val now = Clock.System.now().epochSeconds + val now = nowSeconds() val kept = m.filterValues { now - it.at < 7 * NAME_RESOLVED_DAY_SECONDS } try { ChatController.appPrefs.simplexNamesResolvedAt.set(json.encodeToString>(kept)) @@ -96,28 +95,24 @@ private fun saveNamesResolvedAt(m: Map) { } } -// a cached answer is stale a day after it was taken, or as soon as the name it described expired fun simplexNameResolvedRecently(domain: SimplexDomain): Boolean { val r = loadNamesResolvedAt()[domain.fullDomainName] ?: return false - val now = Clock.System.now().epochSeconds + val now = nowSeconds() if (now - r.at >= NAME_RESOLVED_DAY_SECONDS) return false return r.expires == null || now < r.expires } fun recordSimplexNameResolved(domain: SimplexDomain, reg: NameRegistration?) { val m = loadNamesResolvedAt() - m[domain.fullDomainName] = SimplexNameResolved(Clock.System.now().epochSeconds, (reg as? NameRegistration.Registered)?.expires) + m[domain.fullDomainName] = SimplexNameResolved(nowSeconds(), (reg as? NameRegistration.Registered)?.expires) saveNamesResolvedAt(m) } -// a name registered, claimed or dropped on this device must not keep reading as it did before fun forgetSimplexNameResolved(fullDomainName: String) { val m = loadNamesResolvedAt() if (m.remove(fullDomainName) != null) saveNamesResolvedAt(m) } - -// what the registry said, when the user can act on it; false leaves the caller to show its usual plan UI. private fun showNameRegistrationAlert( rhId: Long?, domain: SimplexDomain, @@ -129,13 +124,12 @@ private fun showNameRegistrationAlert( cleanup: (() -> Unit)? ): Boolean { val nameStr = domain.fullDomainName - val now = Clock.System.now().epochSeconds + val now = nowSeconds() val ok = generalGetString(MR.strings.ok) fun dismiss() { AlertManager.privacySensitive.hideAlert() cleanup?.invoke() } - // an extra way back to the chat the user already has, when there is no filtered list behind the alert fun buttons(action: Pair Unit>?): @Composable () -> Unit = { val uriHandler = LocalUriHandler.current Column { @@ -159,7 +153,6 @@ private fun showNameRegistrationAlert( } val expired = reg.expired(now) return when { - // expired, and only its owner can renew it until the grace ends expired && reg is NameRegistration.Registered -> { val until = reg.graceUntil if (isOwn) { @@ -175,31 +168,23 @@ private fun showNameRegistrationAlert( } true } - // free to register, unless it is shorter than the registry accepts reg is NameRegistration.Available && domain.domain.length >= reg.pricing.minLabelLength -> { val price = nameCentsPerYear(reg.pricing, domain) - when { - isOwn -> { - alert( - generalGetString(MR.strings.simplex_name_own_expired), - String.format(generalGetString(MR.strings.simplex_name_own_available_desc), nameStr, price), - generalGetString(MR.strings.simplex_name_re_register) to { uh: UriHandler -> openNameHowTo(uh) } - ) - } - hasLocalChat -> alert( - generalGetString(MR.strings.simplex_name_no_longer_registered), - String.format(generalGetString(MR.strings.simplex_name_available_desc), nameStr, price), - generalGetString(MR.strings.simplex_name_register) to { uh: UriHandler -> openNameHowTo(uh) } + if (isOwn) { + alert( + generalGetString(MR.strings.simplex_name_own_expired), + String.format(generalGetString(MR.strings.simplex_name_own_available_desc), nameStr, price), + generalGetString(MR.strings.simplex_name_re_register) to { uh: UriHandler -> openNameHowTo(uh) } ) - else -> alert( - generalGetString(MR.strings.simplex_name_not_registered), + } else { + alert( + generalGetString(if (hasLocalChat) MR.strings.simplex_name_no_longer_registered else MR.strings.simplex_name_not_registered), String.format(generalGetString(MR.strings.simplex_name_available_desc), nameStr, price), generalGetString(MR.strings.simplex_name_register) to { uh: UriHandler -> openNameHowTo(uh) } ) } true } - // the only reserved reason that says so; every other one reads as "not registered" reg.reservedForCommunity -> { alert( generalGetString(MR.strings.simplex_name_not_registered), @@ -210,14 +195,10 @@ private fun showNameRegistrationAlert( ) true } - // too short, reserved for another reason, or otherwise not registrable - reg is NameRegistration.Reserved || reg is NameRegistration.Available -> { - if (hasLocalChat || isOwn) false else { - alert(generalGetString(MR.strings.simplex_name_not_registered), generalGetString(MR.strings.simplex_name_not_found_desc)) - true - } + (reg is NameRegistration.Reserved || reg is NameRegistration.Available) && !hasLocalChat && !isOwn -> { + alert(generalGetString(MR.strings.simplex_name_not_registered), generalGetString(MR.strings.simplex_name_not_found_desc)) + true } - // registered and live, but no usable link - only the plan knows, a connectable one has nothing to say. reg is NameRegistration.Registered && notConnectable && !hasLocalChat && !isOwn -> { alert( generalGetString(MR.strings.simplex_name_no_valid_link), @@ -225,7 +206,6 @@ private fun showNameRegistrationAlert( ) true } - // still leads to the chat you have, nothing to say else -> false } } @@ -250,17 +230,14 @@ private suspend fun planAndConnectTask( cleanup?.invoke() completable.complete(!completable.isActive) } - // A fresh name is tried against the store first; a miss falls through to a full resolution. val nameTarget = (strConnectTarget(shortOrFullLink.trim()) as? ConnectTarget.Name)?.nameInfo val freshName = nameTarget != null && simplexNameResolvedRecently(nameTarget.nameDomain) var result = if (freshName) { - // a local probe: no spinner and no error alerts, so a miss falls through silently chatModel.controller.apiConnectPlan(rhId, shortOrFullLink, PlanResolveMode.PRMNever, linkOwnerSig, mutableStateOf(false)) } else null if (result == null && inProgress.value) { val mode = if (nameTarget != null) PlanResolveMode.PRMAll else PlanResolveMode.PRMUnknown result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink, mode, linkOwnerSig, inProgress) - // remember when this name was last taken from the registry, and when its registration runs out if (nameTarget != null && result != null) { recordSimplexNameResolved(nameTarget.nameDomain, result.connectionPlan.nameRegistration()) } @@ -277,7 +254,6 @@ private suspend fun planAndConnectTask( 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) } - // One actionable answer from the registry is one alert, shown over whatever the plan would have. val nameReg = when (connectionPlan) { is ConnectionPlan.NameNotConnectable -> connectionPlan.nameRegistration is ConnectionPlan.ContactAddress -> connectionPlan.nameRegistration_ @@ -293,7 +269,6 @@ private suspend fun planAndConnectTask( val knownGroup = (connectionPlan as? ConnectionPlan.GroupLink)?.groupLinkPlan.let { it as? GroupLinkPlan.Known }?.groupInfo val isOwnName = (connectionPlan as? ConnectionPlan.ContactAddress)?.contactAddressPlan is ContactAddressPlan.OwnLink || (connectionPlan as? ConnectionPlan.GroupLink)?.groupLinkPlan is GroupLinkPlan.OwnLink - // the chat found stays reachable: filtered into the list where there is one, opened from the alert where there is not var openExisting: (() -> Unit)? = null if (knownContact != null) { if (filterKnownContact != null) filterKnownContact(knownContact) @@ -315,7 +290,6 @@ private suspend fun planAndConnectTask( if (handled) return completable } val connectionLink = connectionLink_ ?: run { - // a name with nothing to connect to has no link; an actionable answer was already alerted above when (connectionPlan) { is ConnectionPlan.NameNotConnectable -> AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.simplex_name_not_registered), @@ -328,7 +302,6 @@ private suspend fun planAndConnectTask( } when (connectionPlan) { is ConnectionPlan.NameNotConnectable -> { - // nothing actionable and nothing to connect to: as today, the name simply did not resolve AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.simplex_name_not_registered), generalGetString(MR.strings.simplex_name_not_found_desc) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index a063b64032..a18bc4faee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -388,7 +388,6 @@ private fun UserAddressLayout( save = { simplexDomain -> try { val u = chatModel.controller.apiSetUserDomain(user?.remoteHostId, simplexDomain) - // a name claimed or dropped here must not keep reading from the answer taken before domain?.let { forgetSimplexNameResolved(it) } withContext(Dispatchers.Main) { chatModel.updateUser(u) } true diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c0d3aacbcc..123eb573d9 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -4420,50 +4420,44 @@ processChatCommand cxt nm = \case Right reg -> do expired <- nameExpired reg case reg of - -- a live registration with a usable link connects, as before NRRegistered {nameRecord = nr} | not expired && isJust (firstNameLink CCTChannel (nrSimplexChannel nr)) -> - (withReg reg . addOther nr <$> connectPlanName NTPublicGroup (Right reg)) `catchAllErrors` \e -> - (withReg reg . addOther nr <$> connectPlanName NTContact (Right reg) `catchAllErrors` \_ -> throwError e) + (setPlanRegistration reg <$> connectPlanName NTPublicGroup (Right reg)) `catchAllErrors` \e -> + (setPlanRegistration reg <$> connectPlanName NTContact (Right reg) `catchAllErrors` \_ -> throwError e) | not expired && isJust (firstNameLink CCTContact (nrSimplexContact nr)) -> - withReg reg . addOther nr <$> connectPlanName NTContact (Right reg) - -- nothing to connect to: the answer is whichever local chat claims the name, or the registration alone + setPlanRegistration reg <$> connectPlanName NTContact (Right reg) _ -> connectPlanLocal reg Left e -> connectPlanNoName e where - withReg = withPlanRegistration - -- only the local store is consulted: a name with nothing to connect to must not resolve a link connectPlanLocal reg = - (withReg reg <$> localPlan NTPublicGroup) `catchAllErrors` \_ -> - (withReg reg <$> localPlan NTContact) `catchAllErrors` \_ -> + (setPlanRegistration reg <$> localPlan NTPublicGroup) `catchAllErrors` \_ -> + (setPlanRegistration reg <$> localPlan NTContact) `catchAllErrors` \_ -> pure (Nothing, Nothing, Nothing, CPNameNotConnectable d reg) where localPlan nameType = connectPlan user (nameTarget nameType) PRMNever sig_ (Just (Right reg)) - connectPlanName nameType nr_ = connectPlan user (nameTarget nameType) resolveMode sig_ (Just nr_) + connectPlanName nameType nr_ = setOtherName <$> connectPlan user (nameTarget nameType) resolveMode sig_ (Just nr_) + where + setOtherName (l, planName, _, p) = (l, planName, otherName, p) + otherName = case nr_ of + Right NRRegistered {nameRecord = NameRecord {nrSimplexContact, nrSimplexChannel}} -> case nameType of + NTContact -> SimplexNameInfo NTPublicGroup d <$ firstNameLink CCTChannel nrSimplexChannel + NTPublicGroup -> SimplexNameInfo NTContact d <$ firstNameLink CCTContact nrSimplexContact + _ -> Nothing nameTarget nameType = ACTarget SCMContact $ CTShortContact $ CTName $ SimplexNameInfo nameType d connectPlanNoName e = connectPlanName NTPublicGroup (Left e) `catchAllErrors` \e' -> (connectPlanName NTContact (Left e) `catchAllErrors` \_ -> throwError e') - -- the same domain can resolve to both an @ name (contact or business) and a # channel; - -- keyed off the resolved name's type, so a contact name returning a business group still offers the channel - addOther nr (l, planName, _, p) = (l, planName, otherName, p) - where - otherName = case planName of - Just (SimplexNameInfo NTContact _) | isJust (firstNameLink CCTChannel (nrSimplexChannel nr)) -> Just $ SimplexNameInfo NTPublicGroup d - Just (SimplexNameInfo NTPublicGroup _) | isJust (firstNameLink CCTContact (nrSimplexContact nr)) -> Just $ SimplexNameInfo NTContact d - _ -> Nothing CTFullContact cReq -> do plan <- contactOrGroupRequestPlan user cReq `catchAllErrors` (pure . CPError) pure (Just (ACCL SCMContact $ CCLink cReq Nothing), Nothing, Nothing, plan) CTShortContact nl - -- a name given with its type (@name / #name): the registry decides connectability, as for a bare domain | CTName ni <- nl, isNothing nameRec, resolveMode /= PRMNever -> do reg <- resolveNameRegistration user nm (nameDomain ni) expired <- nameExpired reg if nameHasLink ni reg && not expired - then withPlanRegistration reg <$> connectPlan user (ACTarget SCMContact (CTShortContact nl)) resolveMode sig_ (Just (Right reg)) + then setPlanRegistration reg <$> connectPlan user (ACTarget SCMContact (CTShortContact nl)) resolveMode sig_ (Just (Right reg)) else - (withPlanRegistration reg <$> connectPlan user (ACTarget SCMContact (CTShortContact nl)) PRMNever sig_ (Just (Right reg))) + (setPlanRegistration reg <$> connectPlan user (ACTarget SCMContact (CTShortContact nl)) PRMNever sig_ (Just (Right reg))) `catchAllErrors` \_ -> pure (Nothing, Nothing, Nothing, CPNameNotConnectable (nameDomain ni) reg) CTShortContact nl -> (\(l, p) -> (Just l, simplexName_, Nothing, p)) <$> case ctType of @@ -4474,7 +4468,6 @@ processChatCommand cxt nm = \case when (resolveMode == PRMNever) $ throwChatError CENotResolvedLocally l' <- resolveSLink case known_ of - -- the name still leads to the chat that claims it, nothing actionable Just r | knownLinkOf r == Just l' -> pure r _ -> (if isJust known_ then second setAddressChanged else id) <$> resolvedPlan l' where @@ -4528,7 +4521,6 @@ processChatCommand cxt nm = \case CTLink l' -> pure l' CTName n -> serverShortLink <$> resolveNameLink n con l' cReq = ACCL SCMContact $ CCLink cReq (Just l') - -- a known chat is re-resolved only on PRMAll, to see whether it still leads there reResolveKnown (_, p) = resolveMode == PRMAll && case p of CPContactAddress (CAPKnown _) _ -> True CPGroupLink GLPKnown {} _ -> True @@ -4546,7 +4538,6 @@ processChatCommand cxt nm = \case when (resolveMode == PRMNever) $ throwChatError CENotResolvedLocally l' <- resolveSLink case known_ of - -- the name still leads to the channel that claims it, refreshed from the link Just r@(_, CPGroupLink (GLPKnown g _ _ _) _) | knownLinkOf r == Just l' -> resolveKnownGroup l' g _ -> (if isJust known_ then second setAddressChanged else id) <$> resolvedGroupPlan l' where @@ -5128,13 +5119,11 @@ resolveNameRecord user nm domain = NRRegistered {nameRecord} -> pure nameRecord _ -> throwError $ chatErrorAgent $ SMP "" (NAME SMP.NOT_FOUND) --- past its expiry does not connect; an absent expiry (a v20/v21 router sent the record alone) reads as live nameExpired :: NameRegistration -> CM Bool nameExpired = \case NRRegistered {expires = Just expires} -> (expires <) <$> liftIO getSystemSeconds _ -> pure False --- does the name's record hold a link of the type the name asks for? nameHasLink :: SimplexNameInfo -> NameRegistration -> Bool nameHasLink SimplexNameInfo {nameType} = \case NRRegistered {nameRecord = NameRecord {nrSimplexContact, nrSimplexChannel}} -> case nameType of @@ -5142,17 +5131,14 @@ nameHasLink SimplexNameInfo {nameType} = \case NTPublicGroup -> isJust (firstNameLink CCTChannel nrSimplexChannel) _ -> False -withPlanRegistration :: NameRegistration -> (a, b, c, ConnectionPlan) -> (a, b, c, ConnectionPlan) -withPlanRegistration reg (l, pn, on, p) = (l, pn, on, withNameRegistration reg p) +setPlanRegistration :: NameRegistration -> (a, b, c, ConnectionPlan) -> (a, b, c, ConnectionPlan) +setPlanRegistration nr (l, pn, on, p) = (l, pn, on, setRegistration p) + where + setRegistration = \case + CPContactAddress pl _ -> CPContactAddress pl (Just nr) + CPGroupLink pl _ -> CPGroupLink pl (Just nr) + pl -> pl --- the registration a resolved name answered with, attached to whichever plan was built for it -withNameRegistration :: NameRegistration -> ConnectionPlan -> ConnectionPlan -withNameRegistration nr = \case - CPContactAddress p _ -> CPContactAddress p (Just nr) - CPGroupLink p _ -> CPGroupLink p (Just nr) - p -> p - --- the name resolved to another address than the one the local chat claiming it holds setAddressChanged :: ConnectionPlan -> ConnectionPlan setAddressChanged = \case CPContactAddress (CAPOk cld ov _) nr -> CPContactAddress (CAPOk cld ov True) nr diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 074c022466..ad14a6a5f4 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -2236,7 +2236,6 @@ otherSimplexNameNote = \case Just ni@(SimplexNameInfo NTContact _) -> [plain $ "You can also connect to " <> shortNameInfoStr ni <> " in direct chat"] Nothing -> [] --- what the registry said about the name, shown where it changes what the plan means viewNameRegistration :: ConnectionPlan -> [StyledString] viewNameRegistration = \case CPContactAddress CAPKnown {} nr_ -> regLine nr_ diff --git a/tests/ChatTests/Names.hs b/tests/ChatTests/Names.hs index ea3c2ef7a5..ef8a35b6a2 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -328,11 +328,9 @@ testConnectByNameBusinessAndChannel ps = withSmpServerAndNames $ \reg -> where bizName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "biz" []) --- One test per name-lookup answer: each sets up the registry answer and asserts the plan the CLI renders. aliceSimplexName :: SimplexNameInfo aliceSimplexName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) --- alice publishes alice.simplex on her address; the registration is left for the caller to change. withAliceName :: HasCallStack => (NameRegistry -> Text -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () withAliceName test ps = withSmpServerAndNames $ \reg -> testChat2 aliceProfile bobProfile (setup reg) ps @@ -346,7 +344,6 @@ withAliceName test ps = withSmpServerAndNames $ \reg -> alice <## "new contact address set" test reg (T.pack shortLink) alice bob --- bob connects to alice by name, so his contact is found by local name search afterwards. connectBobByName :: HasCallStack => TestCC -> TestCC -> IO () connectBobByName alice bob = do bob ##> "/c @alice.simplex" @@ -455,16 +452,13 @@ testPlanOwnNameAvailable = withAliceName $ \reg _l alice _bob -> do testPlanNameResolveNever :: HasCallStack => TestParams -> IO () testPlanNameResolveNever = withAliceName $ \_reg _l alice bob -> do connectBobByName alice bob - -- a hit answers from the store, with no registration attached bob ##> "/_connect plan 1 @alice.simplex resolve=never" bob <## "contact address: known contact alice" bob <## "SimpleX name: @alice.simplex (verified)" bob <## "use @alice to send messages" - -- a miss is not resolved online, and is reported as such bob ##> "/_connect plan 1 @nobody.simplex resolve=never" bob <## "no matching chat found, name resolution is disabled" --- the name is re-pointed at cath's address; only resolve=all re-resolves a name whose chat is known. testPlanKnownNameAddressChanged :: HasCallStack => TestParams -> IO () testPlanKnownNameAddressChanged ps = withSmpServerAndNames $ \reg -> testChat3 aliceProfile bobProfile cathProfile (test reg) ps @@ -477,29 +471,24 @@ testPlanKnownNameAddressChanged ps = withSmpServerAndNames $ \reg -> alice ##> "/_set domain 1 alice.simplex" alice <## "new contact address set" connectBobByName alice bob - -- the name now leads to cath, who claims it cath ##> "/ad" (cathLink, _) <- getContactLinks cath True registerName reg aliceSimplexName (contactNameRecord "alice.simplex" (T.pack cathLink)) cath ##> "/_set domain 1 alice.simplex" cath <## "new contact address set" - -- resolve=unknown keeps answering from the store, so the move is not noticed bob ##> "/_connect plan 1 @alice.simplex" bob <## "contact address: known contact alice" bob <## "SimpleX name: @alice.simplex (verified)" bob <## "use @alice to send messages" bob <## "registered" - -- resolve=all re-resolves it and reports the new address bob ##> "/_connect plan 1 @alice.simplex resolve=all" bob <## "contact address: ok to connect, address changed" _ <- getTermLine bob -- the new address's short link data (JSON, printed in test view) - -- the chat bob already has is still what a local-only lookup returns bob ##> "/_connect plan 1 @alice.simplex resolve=never" bob <## "contact address: known contact alice" bob <## "SimpleX name: @alice.simplex (verified)" bob <## "use @alice to send messages" --- the registry could not be asked. As today, this stays an error rather than a plan. testPlanNameResolverFailed :: HasCallStack => TestParams -> IO () testPlanNameResolverFailed = withAliceName $ \reg _l _alice bob -> do failNameResolution reg broken diff --git a/tests/NameResolver.hs b/tests/NameResolver.hs index 18a112a73c..9cfdfd96ae 100644 --- a/tests/NameResolver.hs +++ b/tests/NameResolver.hs @@ -38,7 +38,6 @@ import Simplex.Messaging.Server.Names (NamesConfig (..)) import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexNameInfo (..), labelHash) import Simplex.Messaging.SystemTime (RoundedSystemTime (..), getSystemSeconds) --- what the test resolver answers for a name: a registration, or a failed request data TestNameAnswer = AnswerRegistration NameRegistration | AnswerFails type NameRegistry = TVar (Map Text TestNameAnswer)