diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 0c9825eda7..abca9a6cd6 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1459,77 +1459,35 @@ enum PlanResolveMode: String { enum ConnectionPlan: Decodable, Hashable { case invitationLink(invitationLinkPlan: InvitationLinkPlan) - case contactAddress(contactAddressPlan: ContactAddressPlan, nameRegistration_: NameRegistration?) - case groupLink(groupLinkPlan: GroupLinkPlan, nameRegistration_: NameRegistration?) - case nameNotConnectable(simplexDomain: SimplexDomain, nameRegistration: NameRegistration) + case contactAddress(contactAddressPlan: ContactAddressPlan, nameWarning_: NameWarning?) + case groupLink(groupLinkPlan: GroupLinkPlan, nameWarning_: NameWarning?) + case nameNotConnectable(simplexDomain: SimplexDomain, nameWarning: NameWarning) case error(chatError: ChatError) - var nameRegistration: NameRegistration? { + var nameWarning: NameWarning? { switch self { - case let .contactAddress(_, reg): reg - case let .groupLink(_, reg): reg - case let .nameNotConnectable(_, reg): reg + case let .contactAddress(_, warning): warning + case let .groupLink(_, warning): warning + case let .nameNotConnectable(_, warning): warning default: nil } } } -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?) - case available(pricing: NamePricing) - case reserved(reservedReason: String) - - // the registry may add reasons after this version, so any other value is just "not registrable" - static let reservedCommunity = "community" - - func expired(_ now: Int64) -> Bool { - if case let .registered(expires, _, _) = self, let expires { expires < now } else { false } - } - - var reservedForCommunity: Bool { - switch self { - case let .reserved(reason): reason == NameRegistration.reservedCommunity - case let .registered(_, _, reason): reason == NameRegistration.reservedCommunity - case .available: false - } - } +enum NameWarning: Decodable, Hashable { + case expired(expiredAt: Date, graceUntil: Date?) + case ownExpired(expiredAt: Date, graceUntil: Date?) + case available(price: NamePrice) + case noLongerRegistered(price: NamePrice) + case ownAvailable(price: NamePrice) + case reservedForCommunity + case notRegistered + case noValidLink } -extension NameRegistration: Decodable { - private enum CodingKeys: String, CodingKey { - case type, expires, graceUntil, reservedReason_, pricing, reservedReason - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: .type) - switch type { - case "registered": - let expires = try container.decodeIfPresent(Int64.self, forKey: .expires) - let graceUntil = try container.decodeIfPresent(Int64.self, forKey: .graceUntil) - let reservedReason_ = try container.decodeIfPresent(String.self, forKey: .reservedReason_) - self = .registered(expires: expires, graceUntil: graceUntil, reservedReason_: reservedReason_) - case "available": - let pricing = try container.decode(NamePricing.self, forKey: .pricing) - self = .available(pricing: pricing) - case "reserved": - let reservedReason = try container.decode(String.self, forKey: .reservedReason) - self = .reserved(reservedReason: reservedReason) - default: - throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown NameRegistration type: \(type)") - } - } -} - -struct NamePricing: Decodable, Hashable { - var registrationPrices: [String: Int64] - var basePrice: Int64 - var minLabelLength: Int - - func centsPerYear(_ labelLength: Int) -> Int64 { - registrationPrices[String(labelLength)] ?? basePrice - } +struct NamePrice: Decodable, Hashable { + var amount: Int64 + var years: Int } enum InvitationLinkPlan: Decodable, Hashable { diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 86ad421c8b..d32e3fe0aa 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1120,6 +1120,7 @@ private func showPrepareContactAlert( connectOtherButton: String? = nil, connectOtherLink: String? = nil, addressChanged: Bool = false, + openExistingChat: (() -> Void)? = nil, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? @@ -1141,10 +1142,10 @@ private func showPrepareContactAlert( theme: theme, subtitle: addressChanged ? verifiedDomain.map { String.localizedStringWithFormat(NSLocalizedString("%@ now leads to a new address.", comment: "alert subtitle"), $0.fullDomainName) } : nil, information: ownerVerificationMessage(ownerVerification), - cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), + cancelTitle: openExistingChat == nil ? NSLocalizedString("Cancel", comment: "new chat action") : NSLocalizedString("Open existing chat", comment: "alert action"), confirmTitle: NSLocalizedString("Open new chat", comment: "new chat action"), secondTitle: connectOtherButton, - onCancel: { cleanup?() }, + onCancel: openExistingChat ?? { cleanup?() }, onConfirm: { Task { do { @@ -1321,26 +1322,22 @@ private func showOpenKnownGroupAlert( private let simplexNamesHowToURL = "https://simplex.domains/#testing" -private func nameDate(_ seconds: Int64) -> String { - Date(timeIntervalSince1970: TimeInterval(seconds)).formatted(date: .abbreviated, time: .omitted) +private func nameDate(_ date: Date) -> String { + date.formatted(date: .abbreviated, time: .omitted) } -private func nameCentsPerYear(_ pricing: NamePricing, _ domain: SimplexDomain) -> String { - let cents = pricing.centsPerYear(domain.domain.count) - return "$\(cents / 100)" + (cents % 100 == 0 ? "" : String(format: ".%02lld", cents % 100)) +private func namePrice(_ price: NamePrice) -> String { + let dollars = "$\(price.amount / 100)" + (price.amount % 100 == 0 ? "" : String(format: ".%02lld", price.amount % 100)) + return String.localizedStringWithFormat(NSLocalizedString("%1$@ for %2$d years", comment: "name price"), dollars, price.years) } -private func showNameRegistrationAlert( +private func showNameWarningAlert( domain: SimplexDomain, - reg: NameRegistration, - isOwn: Bool, - notConnectable: Bool, - hasLocalChat: Bool, + warning: NameWarning, openExistingChat: (() -> Void)?, cleanup: (() -> Void)? -) -> Bool { +) { let nameStr = domain.fullDomainName - let now = Int64(Date.now.timeIntervalSince1970) func alert(_ title: String, _ message: String, action: (title: String, handler: () -> Void)? = nil) { showAlert(title, message: message, actions: { var actions: [UIAlertAction] = [] @@ -1355,43 +1352,41 @@ private func showNameRegistrationAlert( }) } let openHowTo = { openBrowserAlert(uri: simplexNamesHowToURL) } - switch reg { - case let .registered(expires, graceUntil, _) where reg.expired(now): - if isOwn { - let message = if let expires, let graceUntil { - String.localizedStringWithFormat(NSLocalizedString("Your name %1$@ expired on %2$@. Renew it before %3$@.", comment: "alert message"), nameStr, nameDate(expires), nameDate(graceUntil)) - } else { - String.localizedStringWithFormat(NSLocalizedString("Your name %@ has expired. Renew it before the grace period ends.", comment: "alert message"), nameStr) - } - alert(NSLocalizedString("Your name has expired", comment: "alert title"), message, action: (NSLocalizedString("Renew", comment: "alert action"), openHowTo)) + let register = (title: NSLocalizedString("Register", comment: "alert action"), handler: openHowTo) + switch warning { + case let .expired(expiredAt, graceUntil): + let message = if let graceUntil { + String.localizedStringWithFormat(NSLocalizedString("%1$@ expired on %2$@. Its owner can renew it until %3$@.", comment: "alert message"), nameStr, nameDate(expiredAt), nameDate(graceUntil)) } else { - let message = if let expires, let graceUntil { - String.localizedStringWithFormat(NSLocalizedString("%1$@ expired on %2$@. Its owner can renew it until %3$@.", comment: "alert message"), nameStr, nameDate(expires), nameDate(graceUntil)) - } else { - String.localizedStringWithFormat(NSLocalizedString("%@ has expired. Its owner can still renew it for a limited time.", comment: "alert message"), nameStr) - } - alert(NSLocalizedString("Name expired", comment: "alert title"), message) + String.localizedStringWithFormat(NSLocalizedString("%@ has expired. Its owner can still renew it for a limited time.", comment: "alert message"), nameStr) } - return true - case let .available(pricing) where domain.domain.count >= pricing.minLabelLength: - let price = nameCentsPerYear(pricing, domain) - if isOwn { - alert( - NSLocalizedString("Your name has expired", comment: "alert title"), - String.localizedStringWithFormat(NSLocalizedString("Your name %1$@ is no longer registered. It is available for registration from %2$@ per year.", comment: "alert message"), nameStr, price), - action: (NSLocalizedString("Re-register", comment: "alert action"), openHowTo) - ) + alert(NSLocalizedString("Name expired", comment: "alert title"), message) + case let .ownExpired(expiredAt, graceUntil): + let message = if let graceUntil { + String.localizedStringWithFormat(NSLocalizedString("Your name %1$@ expired on %2$@. Renew it before %3$@.", comment: "alert message"), nameStr, nameDate(expiredAt), nameDate(graceUntil)) } else { - alert( - hasLocalChat - ? NSLocalizedString("Name no longer registered", comment: "alert title") - : NSLocalizedString("Name not registered", comment: "alert title"), - String.localizedStringWithFormat(NSLocalizedString("%1$@ is available for registration from %2$@ per year.", comment: "alert message"), nameStr, price), - action: (NSLocalizedString("Register", comment: "alert action"), openHowTo) - ) + String.localizedStringWithFormat(NSLocalizedString("Your name %@ has expired. Renew it before the grace period ends.", comment: "alert message"), nameStr) } - return true - case _ where reg.reservedForCommunity: + alert(NSLocalizedString("Your name has expired", comment: "alert title"), message, action: (NSLocalizedString("Renew", comment: "alert action"), openHowTo)) + case let .available(price): + alert( + NSLocalizedString("Name not registered", comment: "alert title"), + String.localizedStringWithFormat(NSLocalizedString("%1$@ is available for registration for %2$@.", comment: "alert message"), nameStr, namePrice(price)), + action: register + ) + case let .noLongerRegistered(price): + alert( + NSLocalizedString("Name no longer registered", comment: "alert title"), + String.localizedStringWithFormat(NSLocalizedString("%1$@ is available for registration for %2$@.", comment: "alert message"), nameStr, namePrice(price)), + action: register + ) + case let .ownAvailable(price): + alert( + NSLocalizedString("Your name has expired", comment: "alert title"), + String.localizedStringWithFormat(NSLocalizedString("Your name %1$@ is no longer registered. It is available for registration for %2$@.", comment: "alert message"), nameStr, namePrice(price)), + action: (NSLocalizedString("Re-register", comment: "alert action"), openHowTo) + ) + case .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), @@ -1399,22 +1394,43 @@ private func showNameRegistrationAlert( ChatModel.shared.appOpenUrl = simplexTeamURL }) ) - return true - case .reserved, .available: - if hasLocalChat || isOwn { return false } + case .notRegistered: alert(NSLocalizedString("Name not registered", comment: "alert title"), NSLocalizedString("This SimpleX name is not registered. Please check the name.", comment: "")) - return true - case .registered where notConnectable && !hasLocalChat && !isOwn: + case .noValidLink: alert( NSLocalizedString("No valid link", comment: ""), String.localizedStringWithFormat(NSLocalizedString("The SimpleX name %@ is registered, but it has no valid link.", comment: ""), nameStr) ) - return true - default: - return false } } +// a name tapped in a message has no filtered list behind the alert, so the chat the name had is found and opened here +private func openLocalNameChat(_ name: SimplexNameInfo, dismiss: Bool, cleanup: (() -> Void)?) { + Task { + let chatId = knownChatId(await apiConnectPlan(connLink: name.shortStr, resolveMode: .never, inProgress: BoxedValue(false))) + await MainActor.run { + if let chatId { + openKnownChat(chatId, dismiss: dismiss, cleanup: cleanup) + } else { + cleanup?() + } + } + } +} + +// a bare name found as a local chat also leads to the other kind, which the user does not have +private func showOtherNameAlert(_ planSimplexName: SimplexNameInfo, _ otherSimplexName: SimplexNameInfo, connectOtherButton: String, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)?) { + showAlert( + String.localizedStringWithFormat(NSLocalizedString("%1$@ also leads to %2$@", comment: "alert title"), planSimplexName.nameDomain.fullDomainName, otherSimplexName.shortStr), + actions: {[ + UIAlertAction(title: connectOtherButton, style: .default) { _ in + planAndConnect(otherSimplexName.shortStr, theme: theme, dismiss: dismiss, cleanup: cleanup) + }, + UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default) + ]} + ) +} + // Spec: spec/client/navigation.md#planAndConnect func planAndConnect( _ shortOrFullLink: String, @@ -1468,15 +1484,10 @@ func planAndConnect( ) } let nameDomain: SimplexDomain? = if case let .nameNotConnectable(simplexDomain, _) = connectionPlan { simplexDomain } else { planSimplexName?.nameDomain } - if let nameReg = connectionPlan.nameRegistration, let nameDomain { + if let nameWarning = connectionPlan.nameWarning, let nameDomain { let knownContact: Contact? = if case let .contactAddress(.known(contact), _) = connectionPlan { contact } else { nil } let knownGroup: GroupInfo? = if case let .groupLink(.known(groupInfo), _) = connectionPlan { groupInfo } else { nil } - let isOwnName = switch connectionPlan { - case .contactAddress(.ownLink, _), .groupLink(.ownLink, _): true - default: false - } - let notConnectable = if case .nameNotConnectable = connectionPlan { true } else { false } - let handled = await MainActor.run { + await MainActor.run { var openExisting: (() -> Void)? = nil if let contact = knownContact { if ChatModel.shared.getContactChat(contact.contactId) == nil { @@ -1497,17 +1508,9 @@ func planAndConnect( openExisting = { openKnownGroup(groupInfo, dismiss: dismiss, cleanup: nil) } } } - return showNameRegistrationAlert( - domain: nameDomain, - reg: nameReg, - isOwn: isOwnName, - notConnectable: notConnectable, - hasLocalChat: knownContact != nil || knownGroup != nil, - openExistingChat: openExisting, - cleanup: cleanup - ) + showNameWarningAlert(domain: nameDomain, warning: nameWarning, openExistingChat: openExisting, cleanup: cleanup) } - if handled { return } + return } guard let connectionLink = result.connLink else { await MainActor.run { cleanup?() } @@ -1585,6 +1588,10 @@ func planAndConnect( if let contactSLinkData = contactSLinkData_ { logger.debug("planAndConnect, .contactAddress, .ok, short link data present") await MainActor.run { + var openExisting: (() -> Void)? = nil + if filterKnownContact == nil && addressChanged, let planSimplexName { + openExisting = { openLocalNameChat(planSimplexName, dismiss: dismiss, cleanup: cleanup) } + } showPrepareContactAlert( connectionLink: connectionLink, contactShortLinkData: contactSLinkData, @@ -1593,6 +1600,7 @@ func planAndConnect( connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, addressChanged: addressChanged, + openExistingChat: openExisting, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1652,6 +1660,9 @@ func planAndConnect( } if let f = filterKnownContact { f(contact) + if let planSimplexName, let otherSimplexName = result.otherSimplexName, let connectOtherButton { + showOtherNameAlert(planSimplexName, otherSimplexName, connectOtherButton: connectOtherButton, theme: theme, dismiss: dismiss, cleanup: cleanup) + } } else { showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink) } @@ -1737,6 +1748,9 @@ func planAndConnect( } if let f = filterKnownGroup { f(groupInfo) + if let planSimplexName, let otherSimplexName = result.otherSimplexName, let connectOtherButton { + showOtherNameAlert(planSimplexName, otherSimplexName, connectOtherButton: connectOtherButton, theme: theme, dismiss: dismiss, cleanup: cleanup) + } } else { showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink) } 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 7e98be9604..8f5ef06d2c 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 @@ -5253,38 +5253,19 @@ enum class SimplexTLD { } @Serializable -sealed class NameRegistration { - // held by someone; expires/graceUntil are absent from an older router, which means "not known", not "live forever" - @Serializable @SerialName("registered") class Registered( - val expires: Long? = null, - val graceUntil: Long? = null, - val reservedReason_: String? = null - ): NameRegistration() - @Serializable @SerialName("available") class Available(val pricing: NamePricing): NameRegistration() - @Serializable @SerialName("reserved") class Reserved(val reservedReason: String): NameRegistration() - - fun expired(now: Long): Boolean = this is Registered && expires != null && expires < now - - val reservedForCommunity: Boolean get() = when (this) { - is Reserved -> reservedReason == RESERVED_COMMUNITY - is Registered -> reservedReason_ == RESERVED_COMMUNITY - is Available -> false - } - - companion object { - // the registry may add reasons after this version, so any other value is just "not registrable" - const val RESERVED_COMMUNITY = "community" - } +sealed class NameWarning { + @Serializable @SerialName("expired") class Expired(val expiredAt: Instant, val graceUntil: Instant? = null): NameWarning() + @Serializable @SerialName("ownExpired") class OwnExpired(val expiredAt: Instant, val graceUntil: Instant? = null): NameWarning() + @Serializable @SerialName("available") class Available(val price: NamePrice): NameWarning() + @Serializable @SerialName("noLongerRegistered") class NoLongerRegistered(val price: NamePrice): NameWarning() + @Serializable @SerialName("ownAvailable") class OwnAvailable(val price: NamePrice): NameWarning() + @Serializable @SerialName("reservedForCommunity") object ReservedForCommunity: NameWarning() + @Serializable @SerialName("notRegistered") object NotRegistered: NameWarning() + @Serializable @SerialName("noValidLink") object NoValidLink: NameWarning() } @Serializable -data class NamePricing( - val registrationPrices: Map = emptyMap(), - val basePrice: Long, - val minLabelLength: Int -) { - fun centsPerYear(labelLength: Int): Long = registrationPrices[labelLength.toString()] ?: basePrice -} +data class NamePrice(val amount: Long, val years: Int) @Serializable enum class SimplexNameType { 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 d66c99eb48..df6d0ee30c 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 @@ -7441,15 +7441,15 @@ enum class PlanResolveMode { @Serializable 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() - @Serializable @SerialName("nameNotConnectable") class NameNotConnectable(val simplexDomain: SimplexDomain, val nameRegistration: NameRegistration): ConnectionPlan() + @Serializable @SerialName("contactAddress") class ContactAddress(val contactAddressPlan: ContactAddressPlan, val nameWarning_: NameWarning? = null): ConnectionPlan() + @Serializable @SerialName("groupLink") class GroupLink(val groupLinkPlan: GroupLinkPlan, val nameWarning_: NameWarning? = null): ConnectionPlan() + @Serializable @SerialName("nameNotConnectable") class NameNotConnectable(val simplexDomain: SimplexDomain, val nameWarning: NameWarning): ConnectionPlan() @Serializable @SerialName("error") class Error(val chatError: ChatError): ConnectionPlan() - fun nameRegistration(): NameRegistration? = when (this) { - is ContactAddress -> nameRegistration_ - is GroupLink -> nameRegistration_ - is NameNotConnectable -> nameRegistration + fun nameWarning(): NameWarning? = when (this) { + is ContactAddress -> nameWarning_ + is GroupLink -> nameWarning_ + is NameNotConnectable -> nameWarning else -> null } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 62ba2c10f3..fa9408cdbd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -300,6 +300,7 @@ class AlertManager { connectOtherButton: String? = null, onConnectOther: (() -> Unit)? = null, dismissText: String = generalGetString(MR.strings.cancel_verb), + onDismissButton: (() -> Unit)? = null, onDismiss: (() -> Unit)? = null, ) { showAlert { @@ -413,7 +414,7 @@ class AlertManager { } } TextButton(onClick = { - onDismiss?.invoke() + (onDismissButton ?: onDismiss)?.invoke() hideAlert() }, if (confirmText == null) Modifier.focusRequester(focusRequester) else Modifier) { Text(dismissText) 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 75720658d3..e7e6944992 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 @@ -60,29 +60,25 @@ suspend fun planAndConnect( return planAndConnectTask(rhId, shortOrFullLink, linkOwnerSig, close, cleanup, filterKnownContact, filterKnownGroup, inProgress) } -private fun nameDate(seconds: Long): String = - Instant.fromEpochSeconds(seconds).toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() +private fun nameDate(t: Instant): String = + t.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() -private fun nameCentsPerYear(pricing: NamePricing, domain: SimplexDomain): String { - val cents = pricing.centsPerYear(domain.domain.length) - return "$" + (cents / 100).toString() + (if (cents % 100 == 0L) "" else ".%02d".format(cents % 100)) +private fun namePrice(price: NamePrice): String { + val dollars = "$" + (price.amount / 100).toString() + (if (price.amount % 100 == 0L) "" else ".%02d".format(price.amount % 100)) + return String.format(generalGetString(MR.strings.simplex_name_price_for_years), dollars, price.years) } private fun openNameHowTo(uriHandler: UriHandler) = openBrowserAlert(SIMPLEX_NAMES_HOWTO_URL, uriHandler) private const val SIMPLEX_NAMES_HOWTO_URL = "https://simplex.domains/#testing" -private fun showNameRegistrationAlert( +private fun showNameWarningAlert( domain: SimplexDomain, - reg: NameRegistration, - isOwn: Boolean, - notConnectable: Boolean, - hasLocalChat: Boolean, + warning: NameWarning, openExistingChat: (() -> Unit)?, cleanup: (() -> Unit)? -): Boolean { +) { val nameStr = domain.fullDomainName - val now = Clock.System.now().epochSeconds val ok = generalGetString(MR.strings.ok) fun dismiss() { AlertManager.privacySensitive.hideAlert() @@ -109,62 +105,44 @@ private fun showNameRegistrationAlert( fun alert(title: String, text: String, action: Pair Unit>? = null) { AlertManager.privacySensitive.showAlertDialogButtonsColumn(title = title, text = text, onDismissRequest = { cleanup?.invoke() }, buttons = buttons(action)) } - val expired = reg.expired(now) - return when { - expired && reg is NameRegistration.Registered -> { - val until = reg.graceUntil - if (isOwn) { - val text = if (until != null) - String.format(generalGetString(MR.strings.simplex_name_own_expired_desc), nameStr, nameDate(reg.expires!!), nameDate(until)) - else String.format(generalGetString(MR.strings.simplex_name_own_expired_no_date_desc), nameStr) - alert(generalGetString(MR.strings.simplex_name_own_expired), text, generalGetString(MR.strings.simplex_name_renew) to { uh: UriHandler -> openNameHowTo(uh) }) - } else { - val text = if (until != null) - String.format(generalGetString(MR.strings.simplex_name_expired_desc), nameStr, nameDate(reg.expires!!), nameDate(until)) - else String.format(generalGetString(MR.strings.simplex_name_expired_no_date_desc), nameStr) - alert(generalGetString(MR.strings.simplex_name_expired), text) - } - true - } - reg is NameRegistration.Available && domain.domain.length >= reg.pricing.minLabelLength -> { - val price = nameCentsPerYear(reg.pricing, domain) - 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(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 - } - reg.reservedForCommunity -> { - alert( - generalGetString(MR.strings.simplex_name_not_registered), - String.format(generalGetString(MR.strings.simplex_name_reserved_community_desc), nameStr), - generalGetString(MR.strings.simplex_name_connect_simplex_team) to { uh: UriHandler -> - uh.openVerifiedSimplexUri(simplexTeamUri) - } - ) - 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 - } - reg is NameRegistration.Registered && notConnectable && !hasLocalChat && !isOwn -> { - alert( - generalGetString(MR.strings.simplex_name_no_valid_link), - String.format(generalGetString(MR.strings.simplex_name_no_valid_link_desc), nameStr) - ) - true - } - else -> false + val register = generalGetString(MR.strings.simplex_name_register) to { uh: UriHandler -> openNameHowTo(uh) } + when (warning) { + is NameWarning.Expired -> alert( + generalGetString(MR.strings.simplex_name_expired), + if (warning.graceUntil != null) String.format(generalGetString(MR.strings.simplex_name_expired_desc), nameStr, nameDate(warning.expiredAt), nameDate(warning.graceUntil)) + else String.format(generalGetString(MR.strings.simplex_name_expired_no_date_desc), nameStr) + ) + is NameWarning.OwnExpired -> alert( + generalGetString(MR.strings.simplex_name_own_expired), + if (warning.graceUntil != null) String.format(generalGetString(MR.strings.simplex_name_own_expired_desc), nameStr, nameDate(warning.expiredAt), nameDate(warning.graceUntil)) + else String.format(generalGetString(MR.strings.simplex_name_own_expired_no_date_desc), nameStr), + generalGetString(MR.strings.simplex_name_renew) to { uh: UriHandler -> openNameHowTo(uh) } + ) + is NameWarning.Available -> alert( + generalGetString(MR.strings.simplex_name_not_registered), + String.format(generalGetString(MR.strings.simplex_name_available_desc), nameStr, namePrice(warning.price)), + register + ) + is NameWarning.NoLongerRegistered -> alert( + generalGetString(MR.strings.simplex_name_no_longer_registered), + String.format(generalGetString(MR.strings.simplex_name_available_desc), nameStr, namePrice(warning.price)), + register + ) + is NameWarning.OwnAvailable -> alert( + generalGetString(MR.strings.simplex_name_own_expired), + String.format(generalGetString(MR.strings.simplex_name_own_available_desc), nameStr, namePrice(warning.price)), + generalGetString(MR.strings.simplex_name_re_register) to { uh: UriHandler -> openNameHowTo(uh) } + ) + NameWarning.ReservedForCommunity -> alert( + generalGetString(MR.strings.simplex_name_not_registered), + String.format(generalGetString(MR.strings.simplex_name_reserved_community_desc), nameStr), + generalGetString(MR.strings.simplex_name_connect_simplex_team) to { uh: UriHandler -> uh.openVerifiedSimplexUri(simplexTeamUri) } + ) + NameWarning.NotRegistered -> alert(generalGetString(MR.strings.simplex_name_not_registered), generalGetString(MR.strings.simplex_name_not_found_desc)) + NameWarning.NoValidLink -> alert( + generalGetString(MR.strings.simplex_name_no_valid_link), + String.format(generalGetString(MR.strings.simplex_name_no_valid_link_desc), nameStr) + ) } } @@ -201,16 +179,14 @@ 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) } - val nameReg = connectionPlan.nameRegistration() + val nameWarning = connectionPlan.nameWarning() val nameDomain = when (connectionPlan) { is ConnectionPlan.NameNotConnectable -> connectionPlan.simplexDomain else -> planSimplexName?.nameDomain } - if (nameReg != null && nameDomain != null) { + if (nameWarning != null && nameDomain != null) { val knownContact = (connectionPlan as? ConnectionPlan.ContactAddress)?.contactAddressPlan.let { it as? ContactAddressPlan.Known }?.contact 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 var openExisting: (() -> Unit)? = null if (knownContact != null) { if (filterKnownContact != null) filterKnownContact(knownContact) @@ -219,16 +195,8 @@ private suspend fun planAndConnectTask( if (filterKnownGroup != null) filterKnownGroup(knownGroup) else openExisting = { openKnownGroup(chatModel, rhId, close, knownGroup) } } - val handled = showNameRegistrationAlert( - domain = nameDomain, - reg = nameReg, - isOwn = isOwnName, - notConnectable = connectionPlan is ConnectionPlan.NameNotConnectable, - hasLocalChat = knownContact != null || knownGroup != null, - openExistingChat = openExisting, - cleanup = cleanup - ) - if (handled) return completable + showNameWarningAlert(nameDomain, nameWarning, openExisting, cleanup) + return completable } val connectionLink = connectionLink_ ?: run { cleanup() @@ -312,6 +280,9 @@ private suspend fun planAndConnectTask( connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, addressChanged = connectionPlan.contactAddressPlan.addressChanged, + openExistingChat = if (filterKnownContact == null && connectionPlan.contactAddressPlan.addressChanged && planSimplexName != null) { + { openLocalNameChat(rhId, planSimplexName, close, cleanup) } + } else null, close, cleanup ) @@ -373,6 +344,7 @@ private suspend fun planAndConnectTask( } if (filterKnownContact != null) { filterKnownContact(contact) + if (planSimplexName != null && otherSimplexName != null && connectOtherButton != null) showOtherNameAlert(rhId, planSimplexName, otherSimplexName, connectOtherButton, close, cleanup) } else { showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink) cleanup() @@ -478,6 +450,7 @@ private suspend fun planAndConnectTask( } if (filterKnownGroup != null) { filterKnownGroup(groupInfo) + if (planSimplexName != null && otherSimplexName != null && connectOtherButton != null) showOtherNameAlert(rhId, planSimplexName, otherSimplexName, connectOtherButton, close, cleanup) } else { showOpenKnownGroupAlert(chatModel, rhId, close, groupInfo, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink) cleanup() @@ -856,6 +829,7 @@ fun showPrepareContactAlert( connectOtherButton: String? = null, connectOtherLink: String? = null, addressChanged: Boolean = false, + openExistingChat: (() -> Unit)? = null, close: (() -> Unit)?, cleanup: (() -> Unit)? ) { @@ -895,12 +869,44 @@ fun showPrepareContactAlert( }, connectOtherButton = connectOtherButton, onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup) } } }, + dismissText = generalGetString(if (openExistingChat != null) MR.strings.connect_plan_open_existing_chat else MR.strings.cancel_verb), + onDismissButton = openExistingChat, onDismiss = { cleanup?.invoke() } ) } +// a name tapped in a message has no filtered list behind the alert, so the chat the name had is found and opened here +private fun openLocalNameChat(rhId: Long?, name: SimplexNameInfo, close: (() -> Unit)?, cleanup: (() -> Unit)?) { + withBGApi { + val chatId = knownChatId(rhId, chatModel.controller.apiConnectPlan(rhId, name.shortStr, PlanResolveMode.PRMNever, inProgress = mutableStateOf(false))) + val chat = chatId?.let { chatModel.getChat(it) } + if (chat != null) withContext(Dispatchers.Main) { openChat_(chatModel, rhId, close, chat) } + cleanup?.invoke() + } +} + +// a bare name found as a local chat also leads to the other kind, which the user does not have +private fun showOtherNameAlert(rhId: Long?, planSimplexName: SimplexNameInfo, otherSimplexName: SimplexNameInfo, connectOtherButton: String, close: (() -> Unit)?, cleanup: (() -> Unit)?) { + AlertManager.privacySensitive.showAlertDialogButtonsColumn( + title = String.format(generalGetString(MR.strings.simplex_name_also_leads_to), planSimplexName.nameDomain.fullDomainName, otherSimplexName.shortStr), + buttons = { + Column { + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + withBGApi { planAndConnect(rhId, otherSimplexName.shortStr, close = close, cleanup = cleanup) } + }) { + Text(connectOtherButton, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ AlertManager.privacySensitive.hideAlert() }) { + Text(generalGetString(MR.strings.ok), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + fun showPrepareGroupAlert( rhId: Long?, connectionLink: CreatedConnLink, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 0c9e6bc892..7c1b29f737 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -211,12 +211,14 @@ Your name has expired Your name %1$s expired on %2$s. Renew it before %3$s. Your name %1$s has expired. Renew it before the grace period ends. - Your name %1$s is no longer registered. It is available for registration from %2$s per year. + Your name %1$s is no longer registered. It is available for registration for %2$s. Name not registered Name no longer registered - %1$s is available for registration from %2$s per year. + %1$s is available for registration for %2$s. + %1$s for %2$d years %1$s is reserved for community. Please contact SimpleX Chat if you are interested in using it. %1$s now leads to a new address. + %1$s also leads to %2$s Register Re-register Renew diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 6f5ebab3e6..a1449d22ad 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -145,10 +145,8 @@ This file is generated automatically. - [MsgSigStatus](#msgsigstatus) - [MsgVerified](#msgverified) - [NameErrorType](#nameerrortype) -- [NamePricing](#namepricing) -- [NameRecord](#namerecord) -- [NameRegistration](#nameregistration) -- [NameReservedReason](#namereservedreason) +- [NamePrice](#nameprice) +- [NameWarning](#namewarning) - [NetworkError](#networkerror) - [NewUser](#newuser) - [NoteFolder](#notefolder) @@ -1874,17 +1872,17 @@ InvitationLink: ContactAddress: - type: "contactAddress" - contactAddressPlan: [ContactAddressPlan](#contactaddressplan) -- nameRegistration_: [NameRegistration](#nameregistration)? +- nameWarning_: [NameWarning](#namewarning)? GroupLink: - type: "groupLink" - groupLinkPlan: [GroupLinkPlan](#grouplinkplan) -- nameRegistration_: [NameRegistration](#nameregistration)? +- nameWarning_: [NameWarning](#namewarning)? NameNotConnectable: - type: "nameNotConnectable" - simplexDomain: [SimplexDomain](#simplexdomain) -- nameRegistration: [NameRegistration](#nameregistration) +- nameWarning: [NameWarning](#namewarning) Error: - type: "error" @@ -3119,63 +3117,49 @@ RESOLVER: --- -## NamePricing +## NamePrice **Record type**: -- registrationPrices: {int : int64} -- basePrice: int64 -- minLabelLength: int +- amount: int64 +- years: int --- -## NameRecord - -**Record type**: -- name: string -- nickname: string -- website: string -- location: string -- simplexContact: [string] -- simplexChannel: [string] -- eth: string? -- btc: string? -- xmr: string? -- dot: string? -- owner: string -- resolver: string - - ---- - -## NameRegistration +## NameWarning **Discriminated union type**: -Registered: -- type: "registered" -- expires: int64? -- graceUntil: int64? -- reservedReason_: [NameReservedReason](#namereservedreason)? -- nameRecord: [NameRecord](#namerecord) +Expired: +- type: "expired" +- expiredAt: UTCTime +- graceUntil: UTCTime? + +OwnExpired: +- type: "ownExpired" +- expiredAt: UTCTime +- graceUntil: UTCTime? Available: - type: "available" -- pricing: [NamePricing](#namepricing) +- price: [NamePrice](#nameprice) -Reserved: -- type: "reserved" -- reservedReason: [NameReservedReason](#namereservedreason) +NoLongerRegistered: +- type: "noLongerRegistered" +- price: [NamePrice](#nameprice) +OwnAvailable: +- type: "ownAvailable" +- price: [NamePrice](#nameprice) ---- +ReservedForCommunity: +- type: "reservedForCommunity" -## NameReservedReason +NotRegistered: +- type: "notRegistered" -**Enum type**: -- "internal" -- "trademark" -- "community" +NoValidLink: +- type: "noValidLink" --- diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index de38683d98..4b41e600db 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -47,7 +47,7 @@ import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Client import Simplex.Messaging.Crypto.File import Simplex.Messaging.Parsers (dropPrefix, fstToLower) -import Simplex.Messaging.Protocol (BlockingInfo (..), BlockingReason (..), CommandError (..), ErrorType (..), NameErrorType (..), NamePricing (..), NameRecord (..), NameRegistration (..), NameReservedReason (..), NetworkError (..), ProxyError (..)) +import Simplex.Messaging.Protocol (BlockingInfo (..), BlockingReason (..), CommandError (..), ErrorType (..), NameErrorType (..), NetworkError (..), ProxyError (..)) import Simplex.Messaging.Protocol.Types (ClientNotice (..)) import Simplex.Messaging.Transport import Simplex.Chat.Remote.Types (CtrlAppInfo (..)) @@ -187,14 +187,6 @@ removeField n = updateFields $ mapMaybe (\f@(FieldInfo n' _) -> if n == n' then updateFields :: ([FieldInfo] -> [FieldInfo]) -> SumTypeInfo -> SumTypeInfo updateFields f st@(STI _ records) = st {recordTypes = map (\(RecordTypeInfo name fields) -> RecordTypeInfo name $ f fields) records} --- JSON field names drop the "nr" prefix, as NameRecord's deriveJSON does -nameRecordType :: SumTypeInfo -nameRecordType = - let st@(STI _ records) = sti @NameRecord - dropNr (FieldInfo n t) = FieldInfo (dropPrefix "nr" n) t - updateRecord (RecordTypeInfo name fields) = RecordTypeInfo name $ map dropNr fields - in st {recordTypes = map updateRecord records} - -- type info, JSON encoding, constructor prefix, removed constructors, string encoding for commands, description chatTypesDocsData :: [(SumTypeInfo, SumTypeJsonEncoding, String, [ConsName], Expr, Text)] chatTypesDocsData = @@ -343,10 +335,8 @@ chatTypesDocsData = (sti @MsgSigStatus, STEnum, "MSS", [], "", ""), (sti @MsgVerified, STUnion, "MV", [], "", ""), (sti @NameErrorType, STUnion, "", [], "", ""), - (sti @NamePricing, STRecord, "", [], "", ""), - (nameRecordType, STRecord, "", [], "", ""), - (sti @NameRegistration, STUnion, "NR", [], "", ""), - (sti @NameReservedReason, STEnum, "NRR", ["NRRUnknown"], "", ""), + (sti @NamePrice, STRecord, "", [], "", ""), + (sti @NameWarning, STUnion, "NW", [], "", ""), (sti @NetworkError, STUnion, "NE", [], "", ""), (sti @NewUser, STRecord, "", [], "", ""), (sti @NoteFolder, STRecord, "", [], "", ""), @@ -587,10 +577,8 @@ deriving instance Generic MsgReceiptStatus deriving instance Generic MsgSigStatus deriving instance Generic MsgVerified deriving instance Generic NameErrorType -deriving instance Generic NamePricing -deriving instance Generic NameRecord -deriving instance Generic NameRegistration -deriving instance Generic NameReservedReason +deriving instance Generic NamePrice +deriving instance Generic NameWarning deriving instance Generic NetworkError deriving instance Generic NewUser deriving instance Generic NoteFolder diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index 9dc1471516..9105ca0ba4 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -169,7 +169,6 @@ toTypeInfo tr = "AgentUserId" -> ST TInt64 [] "DBEntityId'" -> ST TInt64 [] "Integer" -> ST TInt64 [] - "RoundedSystemTime" -> ST TInt64 [] "USDCents" -> ST TInt64 [] "Version" -> ST TInt [] "VersionRoster" -> ST TInt64 [] diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index da95a55a08..e30bd483eb 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2138,19 +2138,19 @@ export namespace ConnectionPlan { export interface ContactAddress extends Interface { type: "contactAddress" contactAddressPlan: ContactAddressPlan - nameRegistration_?: NameRegistration + nameWarning_?: NameWarning } export interface GroupLink extends Interface { type: "groupLink" groupLinkPlan: GroupLinkPlan - nameRegistration_?: NameRegistration + nameWarning_?: NameWarning } export interface NameNotConnectable extends Interface { type: "nameNotConnectable" simplexDomain: SimplexDomain - nameRegistration: NameRegistration + nameWarning: NameWarning } export interface Error extends Interface { @@ -3398,62 +3398,74 @@ export namespace NameErrorType { } } -export interface NamePricing { - registrationPrices: {[key: number]: number} // int : int64 - basePrice: number // int64 - minLabelLength: number // int +export interface NamePrice { + amount: number // int64 + years: number // int } -export interface NameRecord { - name: string - nickname: string - website: string - location: string - simplexContact: string[] - simplexChannel: string[] - eth?: string - btc?: string - xmr?: string - dot?: string - owner: string - resolver: string -} +export type NameWarning = + | NameWarning.Expired + | NameWarning.OwnExpired + | NameWarning.Available + | NameWarning.NoLongerRegistered + | NameWarning.OwnAvailable + | NameWarning.ReservedForCommunity + | NameWarning.NotRegistered + | NameWarning.NoValidLink -export type NameRegistration = - | NameRegistration.Registered - | NameRegistration.Available - | NameRegistration.Reserved - -export namespace NameRegistration { - export type Tag = "registered" | "available" | "reserved" +export namespace NameWarning { + export type Tag = + | "expired" + | "ownExpired" + | "available" + | "noLongerRegistered" + | "ownAvailable" + | "reservedForCommunity" + | "notRegistered" + | "noValidLink" interface Interface { type: Tag } - export interface Registered extends Interface { - type: "registered" - expires?: number // int64 - graceUntil?: number // int64 - reservedReason_?: NameReservedReason - nameRecord: NameRecord + export interface Expired extends Interface { + type: "expired" + expiredAt: string // ISO-8601 timestamp + graceUntil?: string // ISO-8601 timestamp + } + + export interface OwnExpired extends Interface { + type: "ownExpired" + expiredAt: string // ISO-8601 timestamp + graceUntil?: string // ISO-8601 timestamp } export interface Available extends Interface { type: "available" - pricing: NamePricing + price: NamePrice } - export interface Reserved extends Interface { - type: "reserved" - reservedReason: NameReservedReason + export interface NoLongerRegistered extends Interface { + type: "noLongerRegistered" + price: NamePrice } -} -export enum NameReservedReason { - Internal = "internal", - Trademark = "trademark", - Community = "community", + export interface OwnAvailable extends Interface { + type: "ownAvailable" + price: NamePrice + } + + export interface ReservedForCommunity extends Interface { + type: "reservedForCommunity" + } + + export interface NotRegistered extends Interface { + type: "notRegistered" + } + + export interface NoValidLink extends Interface { + type: "noValidLink" + } } export type NetworkError = diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 870015a766..122cfcbe6c 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -1481,17 +1481,17 @@ class ConnectionPlan_invitationLink(TypedDict): class ConnectionPlan_contactAddress(TypedDict): type: Literal["contactAddress"] contactAddressPlan: "ContactAddressPlan" - nameRegistration_: NotRequired["NameRegistration"] + nameWarning_: NotRequired["NameWarning"] class ConnectionPlan_groupLink(TypedDict): type: Literal["groupLink"] groupLinkPlan: "GroupLinkPlan" - nameRegistration_: NotRequired["NameRegistration"] + nameWarning_: NotRequired["NameWarning"] class ConnectionPlan_nameNotConnectable(TypedDict): type: Literal["nameNotConnectable"] simplexDomain: "SimplexDomain" - nameRegistration: "NameRegistration" + nameWarning: "NameWarning" class ConnectionPlan_error(TypedDict): type: Literal["error"] @@ -2372,49 +2372,53 @@ NameErrorType = NameErrorType_NO_RESOLVER | NameErrorType_NOT_FOUND | NameErrorT NameErrorType_Tag = Literal["NO_RESOLVER", "NOT_FOUND", "RESOLVER"] -class NamePricing(TypedDict): - registrationPrices: dict[int, int] # int : int64 - basePrice: int # int64 - minLabelLength: int # int +class NamePrice(TypedDict): + amount: int # int64 + years: int # int -class NameRecord(TypedDict): - name: str - nickname: str - website: str - location: str - simplexContact: list[str] - simplexChannel: list[str] - eth: NotRequired[str] - btc: NotRequired[str] - xmr: NotRequired[str] - dot: NotRequired[str] - owner: str - resolver: str +class NameWarning_expired(TypedDict): + type: Literal["expired"] + expiredAt: str # ISO-8601 timestamp + graceUntil: NotRequired[str] # ISO-8601 timestamp -class NameRegistration_registered(TypedDict): - type: Literal["registered"] - expires: NotRequired[int] # int64 - graceUntil: NotRequired[int] # int64 - reservedReason_: NotRequired["NameReservedReason"] - nameRecord: "NameRecord" +class NameWarning_ownExpired(TypedDict): + type: Literal["ownExpired"] + expiredAt: str # ISO-8601 timestamp + graceUntil: NotRequired[str] # ISO-8601 timestamp -class NameRegistration_available(TypedDict): +class NameWarning_available(TypedDict): type: Literal["available"] - pricing: "NamePricing" + price: "NamePrice" -class NameRegistration_reserved(TypedDict): - type: Literal["reserved"] - reservedReason: "NameReservedReason" +class NameWarning_noLongerRegistered(TypedDict): + type: Literal["noLongerRegistered"] + price: "NamePrice" -NameRegistration = ( - NameRegistration_registered - | NameRegistration_available - | NameRegistration_reserved +class NameWarning_ownAvailable(TypedDict): + type: Literal["ownAvailable"] + price: "NamePrice" + +class NameWarning_reservedForCommunity(TypedDict): + type: Literal["reservedForCommunity"] + +class NameWarning_notRegistered(TypedDict): + type: Literal["notRegistered"] + +class NameWarning_noValidLink(TypedDict): + type: Literal["noValidLink"] + +NameWarning = ( + NameWarning_expired + | NameWarning_ownExpired + | NameWarning_available + | NameWarning_noLongerRegistered + | NameWarning_ownAvailable + | NameWarning_reservedForCommunity + | NameWarning_notRegistered + | NameWarning_noValidLink ) -NameRegistration_Tag = Literal["registered", "available", "reserved"] - -NameReservedReason = Literal["internal", "trademark", "community"] +NameWarning_Tag = Literal["expired", "ownExpired", "available", "noLongerRegistered", "ownAvailable", "reservedForCommunity", "notRegistered", "noValidLink"] class NetworkError_connectError(TypedDict): type: Literal["connectError"] diff --git a/plans/2026-09-22-name-lookup-core-api.md b/plans/2026-09-22-name-lookup-core-api.md index fee3cc28a3..e371aaeb8e 100644 --- a/plans/2026-09-22-name-lookup-core-api.md +++ b/plans/2026-09-22-name-lookup-core-api.md @@ -57,7 +57,7 @@ Four changes: two to the types in `Controller.hs`, two to `connectPlan` in `Comm **`CPNameNotConnectable` carries its domain.** ```haskell -| CPNameNotConnectable {simplexDomain :: SimplexDomain, nameRegistration :: NameRegistration} +| CPNameNotConnectable {simplexDomain :: SimplexDomain, nameWarning :: NameWarning} ``` `planSimplexName` cannot serve here. It is a `SimplexNameInfo`, which needs a `nameType`, and an unregistered name has none — today's code invents one by trying `NTPublicGroup` then `NTContact` (`Commands.hs:4432-4434`), which is arbitrary and becomes visible the moment the UI renders it. The canvas writes every band-2 body as a bare name (`sunflower.simplex is available…`, `bakery.simplex expired on…`), never `@`/`#`, so the UI wants `fullDomainName`, not `shortStr`. @@ -71,15 +71,15 @@ Four changes: two to the types in `Controller.hs`, two to `connectPlan` in `Comm True when the name resolved to a link that differs from the one held by the local chat that claims this name. This is state 3c and nothing else expresses it: both "a link you do not have" and "a link that replaced yours" are `CAPOk` today. It states that the address moved and not who moved it, which is exactly what the canvas claims ("Only the address change is known, not who made it") and is the narrow form of the `ownerChanged` field dropped in `f3bcd4a16` — no owner identity is carried, stored or compared. -**`connectPlan` returns an optional link and threads the registration.** +**`connectPlan` returns an optional link and takes the registration already resolved.** ```haskell connectPlan :: User -> AConnectTarget -> PlanResolveMode -> Maybe LinkOwnerSig - -> Maybe (Either ChatError NameRegistration) + -> Maybe NameRegistration -> CM (Maybe ACreatedConnLink, Maybe SimplexNameInfo, Maybe SimplexNameInfo, ConnectionPlan) ``` -The fifth parameter is `NameRecord` today (`Commands.hs:4392`); widening it to `NameRegistration` is what lets the recursion carry expiry, pricing and reserved-reason down to the plan that is finally built. `resolveNameLink` (`:4568-4574`) pattern-matches `NRRegistered {nameRecord}` and throws `SDENoValidLink` otherwise, as it effectively does now. +The fifth parameter is the registration the bare name path resolved, so the path of the kind it plans does not resolve the name again. `CPContactAddress` and `CPGroupLink` have `nameWarning_ :: Maybe NameWarning` rather than a registration: see `plans/2026-09-28-name-warnings.md` §5. **`PRMAll` gets its meaning, and replaces `PRMAllGroups`.** It re-resolves a chat that is already known, instead of short-circuiting in `knownLinkPlans`. `PRMAllGroups` did this for groups only, so it is removed and the directory service uses `PRMAll`. @@ -103,54 +103,23 @@ resolveNameRecord user nm domain = _ -> throwError $ chatErrorAgent $ SMP "" (NAME SMP.NOT_FOUND) ``` -**Rewrite the `CTDomain` branch** (`Commands.hs:4415-4442`). Today it resolves, and on anything but a live record with a usable link it calls `connectPlanNoName`, which recurses with a `Left` error and ends in `CPError`. It must instead fall through to the local lookup: - -1. `PRMNever` — unchanged, `CENotResolvedLocally`. -2. Otherwise `resolveNameRegistration`. On a network or protocol failure, unchanged: the error becomes `CPError` (2h). -3. If the answer is `NRRegistered`, **not past `expires`**, and has a usable channel or contact link — today's path: pick the type, recurse as `CTName`, attach the registration to the plan that comes back. -4. Otherwise — `NRAvailable`, `NRReserved`, past `expires`, or no usable link — recurse as `CTName` anyway but **only as far as `knownLinkPlans`**. If it returns an own link or a known chat, that plan is the answer, with the registration attached and `connLink` carrying the stored link. If it returns nothing, the answer is `CPNameNotConnectable d registration` with `connLink = Nothing`. - -Step 4 is the whole of 2b–2f, 3d, 4c and 4d, and it is why `connLink` had to become optional. It needs no new store query: `knownLinkPlans` (`:4482`, `:4553`) already resolves a `CTName` through `getUserContactLinkViaTarget`, `getContactToConnect` and `getGroupToConnect`, all of which match on `cp.contact_domain` / `gp.group_domain` with `*_verified = 1`. +**The plan logic** is described in `plans/2026-09-28-name-warnings.md` §6. For each kind, the path compares the local chat with the name's link. The bare name path looks up both kinds locally, resolves the name once, and plans one kind. When nothing is local and the name has no link of the kind, the answer is `CPNameNotConnectable d warning` with `connLink = Nothing`, which is why `connLink` had to become optional. The local lookups (`getUserContactLinkViaTarget`, `getContactToConnect`, `getGroupToConnect`) match on `cp.contact_domain` / `gp.group_domain` with `*_verified = 1`. **Expiry is a producer rule, not a UI rule.** An expired name must not yield a connectable plan — "It does not connect while only its owner can renew it". `expires` absent (a v20/v21 router sent the record alone) means expiry is unknown, so the name is treated as live — the only safe reading. The dateless fallback in §4 covers the other case: `expires` known and past, `graceUntil` absent. -**`addressChanged`.** Under `PRMAll`, or under `PRMUnknown` when the stored resolution is stale (§9), when the target is a `CTName` and `knownLinkPlans` returns a known contact or group, resolve the link anyway and compare it with the stored one. Equal, or fresh under `PRMUnknown`: today's `CAPKnown` / `GLPKnown`, which is 3a. Different: return the `Ok` plan **for the new link**, with `addressChanged = True` — the canvas draws 3c as a profile card for the new address with *Open new chat*, not as a known chat. Every other construction site passes `False`. +**`addressChanged`.** Under `PRMAll`, or under `PRMUnknown` when the stored resolution is stale (§9), when the target is a `CTName` and the local lookup returns a known contact, a group, or the user's own address or channel, resolve the link anyway and compare it with the stored one. Equal, or fresh under `PRMUnknown`: today's `CAPKnown` / `GLPKnown`, which is 3a. Different: return the `Ok` plan **for the new link**, with `addressChanged = True` — the canvas draws 3c as a profile card for the new address with *Open new chat*, not as a known chat. Every other construction site passes `False`. -**Attach on every name target.** `nameRegistration_` is `Just` whenever the connect target was a name, `Nothing` for a link target, regardless of state — so 2a and 3a carry it too and the UI simply ignores it. One rule beats nine. The exception is a known chat answered from the store (§9): the registry is not asked, so it is `Nothing`. +**Warnings, not registrations.** `nameWarning_` is `Just` exactly when the canvas shows an alert. The registration stays in core (`plans/2026-09-28-name-warnings.md` §5). --- ## 4. The state → plan contract -`d` is the resolved `SimplexDomain`, `nr` the `NameRegistration`. "local" is what `knownLinkPlans` finds for `CTName`. - -| state | registry answer | local | plan | `connLink` | -|---|---|---|---|---| -| 2a | `NRRegistered`, live, link usable | none | `CPContactAddress (CAPOk … False) (Just nr)` or `CPGroupLink (GLPOk … False) (Just nr)` | `Just` resolved | -| 2b | `NRRegistered`, `expires` past | none | `CPNameNotConnectable d nr` | `Nothing` | -| 2c | `NRAvailable {pricing}` | none | `CPNameNotConnectable d nr` | `Nothing` | -| 2d | `NRReserved NRRCommunity` | none | `CPNameNotConnectable d nr` | `Nothing` | -| 2e | `NRReserved` other, or `NRAvailable` with `minLabelLength` > label | none | `CPNameNotConnectable d nr` | `Nothing` | -| 2f | `NRRegistered`, live, no usable link | none | `CPNameNotConnectable d nr` | `Nothing` | -| 2g | link resolved, its profile claims another name or none | — | `CPError (… SDEUnknownDomain)` | unchanged | -| 2h | network or protocol failure | — | `CPError` | unchanged | -| 3a | any, or not asked when fresh (§9) | known chat | `CPContactAddress (CAPKnown ct) (Just nr)` / `CPGroupLink (GLPKnown …) (Just nr)`, with `Nothing` when not asked | `Just` stored | -| 3b | `NRRegistered`, `expires` past | known chat | `CPContactAddress (CAPKnown ct) (Just nr)` | `Just` stored | -| 3c | `NRRegistered`, live, link ≠ stored | known chat | `CPContactAddress (CAPOk … True) (Just nr)` | `Just` resolved | -| 3d | `NRAvailable` or `NRReserved` | known chat | `CPContactAddress (CAPKnown ct) (Just nr)` | `Just` stored | -| 4a | `NRRegistered`, live | own link | `CPContactAddress CAPOwnLink (Just nr)` | `Just` own | -| 4b | registered, leads nowhere of yours | own claim only | `CPError (… SDEUnknownDomain)` | unchanged | -| 4c | `NRRegistered`, `expires` past | own link | `CPContactAddress CAPOwnLink (Just nr)` | `Just` own | -| 4d | `NRAvailable` | own link | `CPContactAddress CAPOwnLink (Just nr)` | `Just` own | - -The two `CPError` rows leave `connLink` exactly as today's code produces it: this change does not reshape the error path. - -Four readings are left to the UI, because core cannot make them without guessing: - -- **2c vs 2e** — `NRAvailable` is 2c when `minLabelLength <= length label`, and 2e otherwise. The registry prices a name it would still refuse; the length check is the client's. The canvas also folds "a subname" into 2e, and how the registry answers for one (`support.acme.simplex`, a non-empty `subDomain`) is not settled by `Record.hs` — B below must establish it against the mock and, if the answer is not already one of these rows, it is a gap to raise on #7525 rather than to paper over here. -- **Band 3 vs band 4** — both are `CAPKnown`/`CAPOwnLink`; which band an alert belongs to is whether the chat is the current user's own. 4b is 2g's error read against the user's own claimed domain, which is why both are `SDEUnknownDomain` and why 4b needs no core change. -- **Dateless variants** — `expires` present and past with `graceUntil` absent means the name is known to have expired but the grace end is unknown. 2b, 3b and 4c then drop the dates ("`bakery.simplex` has expired. Its owner can still renew it for a limited time.") rather than suppressing the alert. `expires` absent entirely never reaches these rows: §3 treats it as live. -- **Price wording** — `NamePricing` is US cents **per year** plus `minLabelLength`; it has no term. The lookup alerts therefore say *from $X per year* and never "for 2 years". The term exists only as `NameCredit.years` on #7530 and belongs on the registration screen, which is the only place it is authoritative. +The contract is the scenario table in `plans/2026-09-28-name-warnings.md` §3. It lists every combination of target (typed or bare name), local chat, and registry answer, with the plan and warning for each. Core now makes the four readings this section used to leave to the UI: +- the label length check (2c vs 2e); +- own vs chat, as separate warnings; +- the dateless variants (`graceUntil` absent); +- the price, as the 2-year term. --- diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 8b0e61ab90..b5e696eac6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -93,7 +93,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), MsgId, NMsgMeta (..), NameRegistration, NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), USDCents, XFTPServer) import Simplex.Messaging.Session (SessionVar) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, TransportPeer (..), simplexMQVersion) @@ -1150,12 +1150,26 @@ data ChatDeleteMode data ConnectionPlan = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} - | CPContactAddress {contactAddressPlan :: ContactAddressPlan, nameRegistration_ :: Maybe NameRegistration} - | CPGroupLink {groupLinkPlan :: GroupLinkPlan, nameRegistration_ :: Maybe NameRegistration} - | CPNameNotConnectable {simplexDomain :: SimplexDomain, nameRegistration :: NameRegistration} + | CPContactAddress {contactAddressPlan :: ContactAddressPlan, nameWarning_ :: Maybe NameWarning} + | CPGroupLink {groupLinkPlan :: GroupLinkPlan, nameWarning_ :: Maybe NameWarning} + | CPNameNotConnectable {simplexDomain :: SimplexDomain, nameWarning :: NameWarning} | CPError {chatError :: ChatError} deriving (Show) +data NameWarning + = NWExpired {expiredAt :: UTCTime, graceUntil :: Maybe UTCTime} + | NWOwnExpired {expiredAt :: UTCTime, graceUntil :: Maybe UTCTime} + | NWAvailable {price :: NamePrice} + | NWNoLongerRegistered {price :: NamePrice} + | NWOwnAvailable {price :: NamePrice} + | NWReservedForCommunity + | NWNotRegistered + | NWNoValidLink + deriving (Eq, Show) + +data NamePrice = NamePrice {amount :: USDCents, years :: Int} + deriving (Eq, Show) + data InvitationLinkPlan = ILPOk {contactSLinkData_ :: Maybe ContactShortLinkData, ownerVerification :: Maybe OwnerVerification} | ILPOwnLink @@ -1861,6 +1875,10 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "DB") ''DatabaseError) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Chat") ''ChatError) +$(JQ.deriveJSON defaultJSON ''NamePrice) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "NW") ''NameWarning) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) $(JQ.deriveJSON defaultJSON ''AppFilePathsConfig) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 540dc730aa..a60a32616c 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -120,10 +120,10 @@ import Simplex.Messaging.Crypto.Ratchet (E2ERatchetParamsUri (..), InitialKeys ( import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), ErrorType (NAME), MsgFlags (..), NameRecord (..), NameRegistration (..), NameResponse (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), ErrorType (NAME), MsgFlags (..), NamePricing (..), NameRecord (..), NameRegistration (..), NameReservedReason (..), NameResponse (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), USDCents (..), UserProtocol, userProtocol) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) -import Simplex.Messaging.SystemTime (getSystemSeconds, roundedToUTCTime) +import Simplex.Messaging.SystemTime (SystemSeconds, getSystemSeconds, roundedToUTCTime) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxyWithAuth) import Simplex.Messaging.Util @@ -4391,7 +4391,7 @@ processChatCommand cxt nm = \case pure (gId, chatSettings) _ -> throwCmdError "not supported" processChatCommand cxt nm $ APISetChatSettings (ChatRef cType chatId Nothing) $ updateSettings chatSettings - connectPlan :: User -> AConnectTarget -> PlanResolveMode -> Maybe LinkOwnerSig -> Maybe (Either ChatError NameRegistration) -> CM (Maybe ACreatedConnLink, Maybe SimplexNameInfo, Maybe SimplexNameInfo, ConnectionPlan) + connectPlan :: User -> AConnectTarget -> PlanResolveMode -> Maybe LinkOwnerSig -> Maybe NameRegistration -> CM (Maybe ACreatedConnLink, Maybe SimplexNameInfo, Maybe SimplexNameInfo, ConnectionPlan) connectPlan user (ACTarget SCMInvitation (CTInv cLink)) _ sig_ _ = case cLink of CLFull cReq -> invitationReqAndPlan cReq Nothing Nothing Nothing CLShort l -> do @@ -4413,83 +4413,49 @@ processChatCommand cxt nm = \case invitationReqAndPlan cReq sLnk_ cld ov = do plan <- invitationRequestPlan user cReq cld ov `catchAllErrors` (pure . CPError) pure (Just (ACCL SCMInvitation (CCLink cReq sLnk_)), Nothing, Nothing, plan) - connectPlan user t@(ACTarget SCMContact ct) PRMUnknown sig_ Nothing - | isName = - tryAllErrors (connectPlan user t PRMNever sig_ Nothing) >>= \case - Right r@(_, _, _, p) -> ifM (resolvedRecently p) (pure r) resolvePlan - Left _ -> resolvePlan - where - isName = case ct of - CTDomain _ -> True - CTShortContact (CTName _) -> True - _ -> False - resolvePlan = connectPlan user t PRMAll sig_ Nothing - resolvedRecently p = do - resolution_ <- withFastStore' $ \db -> case p of - CPContactAddress (CAPKnown ct') _ -> getContactDomainResolution db user ct' - CPGroupLink (GLPKnown g _ _ _) _ -> getGroupDomainResolution db user g - _ -> pure Nothing - now <- liftIO getCurrentTime - pure $ maybe False (\(resolvedAt, expiresAt_) -> diffUTCTime now resolvedAt < nominalDay && maybe True (now <) expiresAt_) resolution_ - connectPlan user (ACTarget SCMContact ct) resolveMode sig_ nameRec = case ct of - CTDomain d - -- local search only: look up #d then @d in the store, without online name resolution - | resolveMode == PRMNever -> connectPlanNoName $ ChatError CENotResolvedLocally - | otherwise -> + connectPlan user (ACTarget SCMContact ct) resolveMode sig_ nameReg_ = case ct of + CTDomain d -> do + channel_ <- knownGroupPlans $ CTName channelName + contact_ <- knownContactPlans $ CTName contactName + let match_ = (channelName,) <$> channel_ <|> (contactName,) <$> contact_ + case match_ of + Just (ni, ((l, p), fresh)) | resolveMode == PRMNever || (resolveMode == PRMUnknown && fresh) -> pure (Just l, Just ni, Nothing, p) + Nothing | resolveMode == PRMNever -> throwChatError CENotResolvedLocally + _ -> tryAllErrors (resolveNameRegistration user nm d) >>= \case + Left e -> case match_ of + Just (ni, ((l, p), _)) | knownChat p -> pure (Just l, Just ni, Nothing, p) + _ -> throwError e Right reg -> do - expired <- nameExpired reg - let connectable nameType = not expired && nameHasLink nameType reg - if - | connectable NTPublicGroup -> - (setPlanRegistration reg <$> connectPlanName NTPublicGroup (Right reg)) `catchAllErrors` \e -> - (setPlanRegistration reg <$> connectPlanName NTContact (Right reg) `catchAllErrors` \_ -> throwError e) - | connectable NTContact -> setPlanRegistration reg <$> connectPlanName NTContact (Right reg) - | otherwise -> connectPlanLocal reg - Left e -> connectPlanNoName e + now <- liftIO getSystemSeconds + let linkOrWarning ni = serverShortLink <$> nameLinkOrWarning now ni reg + namePlan ni = (\(l, planName, _, p) -> (l, planName, otherName ni, p)) <$> connectPlan user (nameTarget ni) resolveMode sig_ (Just reg) + otherName SimplexNameInfo {nameType} = case nameType of + NTPublicGroup -> newName contactName contact_ + NTContact -> newName channelName channel_ + newName ni known_ = case linkOrWarning ni of + Right l' | (knownLinkOf . fst . fst =<< known_) /= Just l' -> Just ni + _ -> Nothing + case match_ of + Just (ni, _) -> namePlan ni + Nothing -> case (linkOrWarning channelName, linkOrWarning contactName) of + (Right _, Right _) -> namePlan channelName `catchAllErrors` \e -> (namePlan contactName `catchAllErrors` \_ -> throwError e) + (Right _, Left _) -> namePlan channelName + (Left _, Right _) -> namePlan contactName + (Left w, Left _) -> pure (Nothing, Nothing, Nothing, CPNameNotConnectable d w) where - connectPlanLocal reg = - (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_ = setOtherName <$> connectPlan user (nameTarget nameType) resolveMode sig_ (Just nr_) - where - setOtherName (l, planName, _, p) = (l, planName, otherName, p) - otherName = case (nameType, nr_) of - (NTContact, Right reg) | nameHasLink NTPublicGroup reg -> Just $ SimplexNameInfo NTPublicGroup d - (NTPublicGroup, Right reg) | nameHasLink NTContact reg -> Just $ SimplexNameInfo NTContact d - _ -> 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') + channelName = SimplexNameInfo NTPublicGroup d + contactName = SimplexNameInfo NTContact d + nameTarget = ACTarget SCMContact . CTShortContact . CTName CTFullContact cReq -> do plan <- contactOrGroupRequestPlan user cReq `catchAllErrors` (pure . CPError) pure (Just (ACCL SCMContact $ CCLink cReq Nothing), Nothing, Nothing, plan) - CTShortContact nl - | CTName ni <- nl, isNothing nameRec, resolveMode /= PRMNever -> do - reg <- resolveNameRegistration user nm (nameDomain ni) - expired <- nameExpired reg - if nameHasLink (nameType ni) reg && not expired - then setPlanRegistration reg <$> connectPlan user (ACTarget SCMContact ct) resolveMode sig_ (Just (Right reg)) - else - (setPlanRegistration reg <$> connectPlan user (ACTarget SCMContact ct) 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 - CCTContact -> - knownLinkPlans >>= \case - Just r | not (reResolveKnown r) -> pure r - known_ -> do - when (resolveMode == PRMNever) $ throwChatError CENotResolvedLocally - l' <- resolveSLink - case known_ of - Just r@(_, p) | knownLinkOf r == Just l' -> r <$ when (isJust simplexName_) (setKnownVerified p) - _ -> (if isJust known_ then second setAddressChanged else id) <$> resolvedPlan l' + (\(l, p) -> (l, simplexName_, Nothing, p)) <$> case ctType of + CCTContact -> knownContactPlans nl' >>= shortLinkPlan confirmKnown resolvedPlan where - resolvedPlan l' = do + confirmKnown expiresAt _ r@(_, p) = r <$ when (isJust simplexName_) (setKnownVerified expiresAt p) + resolvedPlan expiresAt l' = do (FixedLinkData {rootKey}, cData, cReq) <- getShortLinkConnReq nm user l' contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) let linkProfile_ = (\ContactShortLinkData {profile} -> profile) <$> contactSLinkData_ @@ -4516,14 +4482,6 @@ processChatCommand cxt nm = \case ct'' <- refreshContact ct' pure (con l' cReq, CPContactAddress (CAPContactViaAddress ct'') Nothing) _ -> pure (con l' cReq, plan) - knownLinkPlans :: CM (Maybe (ACreatedConnLink, ConnectionPlan)) - knownLinkPlans = withFastStore $ \db -> - liftIO (getUserContactLinkViaTarget db user nl') >>= \case - Just UserContactLink {connLinkContact} -> pure $ Just (ACCL SCMContact connLinkContact, CPContactAddress CAPOwnLink Nothing) - Nothing -> - getContactToConnect db cxt user nl' >>= \case - Just (ccl, ct') -> pure $ if contactDeleted ct' then Nothing else Just (ACCL SCMContact ccl, CPContactAddress (CAPKnown ct') Nothing) - Nothing -> (gPlan =<<) <$> getGroupToConnect db cxt user nl' CCTGroup -> groupShortLinkPlan CCTChannel -> groupShortLinkPlan CCTRelay -> throwCmdError "chat relay links are not supported in this version" @@ -4535,36 +4493,52 @@ processChatCommand cxt nm = \case CTLink (CSLContact _ t _ _) -> t CTName SimplexNameInfo {nameType = NTContact} -> CCTContact CTName SimplexNameInfo {nameType = NTPublicGroup} -> CCTChannel - resolveSLink = case nl' of - CTLink l' -> pure l' - CTName n -> serverShortLink <$> resolveNameLink n con l' cReq = ACCL SCMContact $ CCLink cReq (Just l') - expiresAt = nameExpiresAt =<< eitherToMaybe =<< nameRec - setKnownVerified = \case + setKnownVerified expiresAt = \case CPContactAddress (CAPKnown ct') _ -> void $ withFastStore' $ \db -> setContactDomainVerified db user ct' True expiresAt CPGroupLink (GLPKnown g _ _ _) _ -> void $ withFastStore' $ \db -> setGroupDomainVerified db user g True expiresAt _ -> pure () - reResolveKnown (_, p) = resolveMode == PRMAll && case p of - CPContactAddress (CAPKnown _) _ -> True - CPGroupLink GLPKnown {} _ -> True - _ -> False - knownLinkOf :: (ACreatedConnLink, ConnectionPlan) -> Maybe ShortLinkContact - knownLinkOf (l, _) = case l of - ACCL SCMContact (CCLink _ sl_) -> sl_ - _ -> Nothing - gPlan (ccl, g) = if memberRemoved (membership g) then Nothing else Just (ACCL SCMContact ccl, CPGroupLink (GLPKnown g False Nothing (ListDef [])) Nothing) - groupShortLinkPlan :: CM (ACreatedConnLink, ConnectionPlan) - groupShortLinkPlan = - knownLinkPlans >>= \case - Just r | not (reResolveKnown r) -> pure r - known_ -> do + shortLinkPlan :: + (Maybe UTCTime -> ShortLinkContact -> (ACreatedConnLink, ConnectionPlan) -> CM (ACreatedConnLink, ConnectionPlan)) -> + (Maybe UTCTime -> ShortLinkContact -> CM (ACreatedConnLink, ConnectionPlan)) -> + Maybe ((ACreatedConnLink, ConnectionPlan), Bool) -> + CM (Maybe ACreatedConnLink, ConnectionPlan) + shortLinkPlan confirmKnown resolvedPlan knownFresh_ = case nl' of + CTLink l -> first Just <$> case known_ of + Just r@(_, p) | not (resolveMode == PRMAll && knownChat p) -> pure r + _ -> do when (resolveMode == PRMNever) $ throwChatError CENotResolvedLocally - l' <- resolveSLink - case known_ of - Just r@(_, CPGroupLink (GLPKnown g _ _ _) _) | knownLinkOf r == Just l' -> resolveKnownGroup l' g - _ -> (if isJust known_ then second setAddressChanged else id) <$> resolvedGroupPlan l' + linkPlan Nothing l + CTName ni -> case knownFresh_ of + Just ((l, p), fresh) | resolveMode == PRMNever || (resolveMode == PRMUnknown && fresh) -> pure (Just l, p) + Nothing | resolveMode == PRMNever -> throwChatError CENotResolvedLocally + _ -> + tryAllErrors (maybe (resolveNameRegistration user nm (nameDomain ni)) pure nameReg_) >>= \case + Left e -> case known_ of + Just r@(_, p) | knownChat p -> pure $ first Just r + _ -> throwError e + Right reg -> do + now <- liftIO getSystemSeconds + case nameLinkOrWarning now ni reg of + Right l' -> first Just <$> linkPlan (nameExpiresAt reg) (serverShortLink l') + Left w -> pure $ maybe (Nothing, CPNameNotConnectable (nameDomain ni) w) (bimap Just (setNameWarning w)) known_ where - resolvedGroupPlan l' = do + known_ = fst <$> knownFresh_ + linkPlan expiresAt l' = case known_ of + Just r@(l, _) + | knownLinkOf l == Just l' -> confirmKnown expiresAt l' r + | otherwise -> + (second setAddressChanged <$> resolvedPlan expiresAt l') `catchAllErrors` \case + ChatError (CESimplexDomainNotReady _ SDEUnknownDomain) -> pure r + e -> throwError e + Nothing -> resolvedPlan expiresAt l' + groupShortLinkPlan :: CM (Maybe ACreatedConnLink, ConnectionPlan) + groupShortLinkPlan = knownGroupPlans nl' >>= shortLinkPlan confirmKnown resolvedGroupPlan + where + confirmKnown expiresAt l' = \case + (_, CPGroupLink (GLPKnown g _ _ _) _) -> resolveKnownGroup expiresAt l' g + r -> pure r + resolvedGroupPlan expiresAt l' = do (fd, cData@(ContactLinkData _ UserContactData {direct, owners, relays}), cReq) <- getShortLinkConnReq' nm user l' groupSLinkData_ <- liftIO $ decodeLinkUserData cData if @@ -4602,12 +4576,7 @@ processChatCommand cxt nm = \case unsupportedGroupType = \case Just GroupShortLinkData {groupProfile = GroupProfile {publicGroup = Just PublicGroupProfile {groupType}}} -> groupType /= GTChannel _ -> False - knownLinkPlans :: CM (Maybe (ACreatedConnLink, ConnectionPlan)) - knownLinkPlans = withFastStore $ \db -> - liftIO (getGroupInfoViaUserTarget db cxt user nl') >>= \case - Just (ccl, g) -> pure $ Just (ACCL SCMContact ccl, CPGroupLink (GLPOwnLink g) Nothing) - Nothing -> (gPlan =<<) <$> getGroupToConnect db cxt user nl' - resolveKnownGroup l' g = do + resolveKnownGroup expiresAt l' g = do (FixedLinkData {rootKey = rk}, cData@(ContactLinkData _ UserContactData {owners}), cReq) <- getShortLinkConnReq' nm user l' groupSLinkData_ <- liftIO $ decodeLinkUserData cData let ov = verifyLinkOwner rk owners l' sig_ @@ -4616,17 +4585,43 @@ processChatCommand cxt nm = \case Just sLinkData -> updateGroupFromLinkData user g sLinkData (nameDomain <$> simplexName_) expiresAt _ -> pure (g, False) pure (con l' cReq, CPGroupLink (GLPKnown g' updated ov (ListDef glOwners)) Nothing) - -- resolve a name to its first contact/channel short link - resolveNameLink :: SimplexNameInfo -> CM (ConnShortLink 'CMContact) - resolveNameLink SimplexNameInfo {nameType, nameDomain} = do - reg <- maybe (resolveNameRegistration user nm nameDomain) (ExceptT . pure) nameRec - NameRecord {nrSimplexContact, nrSimplexChannel} <- case reg of - NRRegistered {nameRecord} -> pure nameRecord - _ -> throwChatError $ CESimplexDomainNotReady nameDomain SDENoValidLink - let (candidates, ctType') = case nameType of - NTContact -> (nrSimplexContact, CCTContact) - NTPublicGroup -> (nrSimplexChannel, CCTChannel) - maybe (throwChatError $ CESimplexDomainNotReady nameDomain SDENoValidLink) pure $ firstNameLink ctType' candidates + where + knownContactPlans :: ContactNameOrLink -> CM (Maybe ((ACreatedConnLink, ConnectionPlan), Bool)) + knownContactPlans nl' = withFastStore $ \db -> + liftIO (getUserContactLinkViaTarget db user nl') >>= \case + Just UserContactLink {connLinkContact} -> pure $ Just ((ACCL SCMContact connLinkContact, CPContactAddress CAPOwnLink Nothing), False) + Nothing -> + getContactToConnect db cxt user nl' >>= \case + Just (ccl, ct') + | contactDeleted ct' -> pure Nothing + | otherwise -> do + fresh <- liftIO $ resolvedRecently =<< getContactDomainResolution db user ct' + pure $ Just ((ACCL SCMContact ccl, CPContactAddress (CAPKnown ct') Nothing), fresh) + Nothing -> gPlan db =<< getGroupToConnect db cxt user nl' + knownGroupPlans :: ContactNameOrLink -> CM (Maybe ((ACreatedConnLink, ConnectionPlan), Bool)) + knownGroupPlans nl' = withFastStore $ \db -> + liftIO (getGroupInfoViaUserTarget db cxt user nl') >>= \case + Just (ccl, g) -> pure $ Just ((ACCL SCMContact ccl, CPGroupLink (GLPOwnLink g) Nothing), False) + Nothing -> gPlan db =<< getGroupToConnect db cxt user nl' + gPlan db = \case + Just (ccl, g) | not (memberRemoved (membership g)) -> do + fresh <- liftIO $ resolvedRecently =<< getGroupDomainResolution db user g + pure $ Just ((ACCL SCMContact ccl, CPGroupLink (GLPKnown g False Nothing (ListDef [])) Nothing), fresh) + _ -> pure Nothing + resolvedRecently :: Maybe (UTCTime, Maybe UTCTime) -> IO Bool + resolvedRecently = \case + Just (resolvedAt, expiresAt_) -> do + now <- getCurrentTime + pure $ diffUTCTime now resolvedAt < nominalDay && maybe True (now <) expiresAt_ + Nothing -> pure False + knownChat = \case + CPContactAddress (CAPKnown _) _ -> True + CPGroupLink GLPKnown {} _ -> True + _ -> False + knownLinkOf :: ACreatedConnLink -> Maybe ShortLinkContact + knownLinkOf = \case + ACCL SCMContact (CCLink _ sl_) -> sl_ + _ -> Nothing connectWithPlan :: User -> IncognitoEnabled -> Maybe ACreatedConnLink -> Maybe SimplexNameInfo -> Maybe SimplexNameInfo -> ConnectionPlan -> CM ChatResponse connectWithPlan user@User {userId} incognito ccLink_ planSimplexName otherSimplexName plan | Just ccLink <- ccLink_, connectionPlanProceed plan = do @@ -5142,30 +5137,48 @@ resolveNameRecord user nm domain = reg@NRRegistered {nameRecord} -> pure (nameRecord, nameExpiresAt reg) _ -> throwError $ chatErrorAgent $ SMP "" (NAME SMP.NOT_FOUND) -nameExpired :: NameRegistration -> CM Bool -nameExpired = \case - NRRegistered {expires = Just expires} -> (expires <) <$> liftIO getSystemSeconds - _ -> pure False - nameExpiresAt :: NameRegistration -> Maybe UTCTime nameExpiresAt = \case NRRegistered {expires} -> roundedToUTCTime <$> expires _ -> Nothing -nameHasLink :: SimplexNameType -> NameRegistration -> Bool -nameHasLink nameType = \case - NRRegistered {nameRecord = NameRecord {nrSimplexContact, nrSimplexChannel}} -> case nameType of - NTContact -> isJust (firstNameLink CCTContact nrSimplexContact) - NTPublicGroup -> isJust (firstNameLink CCTChannel nrSimplexChannel) - _ -> False - -setPlanRegistration :: NameRegistration -> (a, b, c, ConnectionPlan) -> (a, b, c, ConnectionPlan) -setPlanRegistration nr (l, pn, on, p) = (l, pn, on, setRegistration p) +-- the name's link of this type, or the warning when there is no local chat for the name +nameLinkOrWarning :: SystemSeconds -> SimplexNameInfo -> NameRegistration -> Either NameWarning ShortLinkContact +nameLinkOrWarning now SimplexNameInfo {nameType, nameDomain = SimplexDomain {domain}} = \case + NRRegistered {expires = Just expires, graceUntil} + | expires < now -> Left $ NWExpired (roundedToUTCTime expires) (roundedToUTCTime <$> graceUntil) + NRRegistered {nameRecord = NameRecord {nrSimplexContact, nrSimplexChannel}} -> + maybe (Left NWNoValidLink) Right $ case nameType of + NTContact -> firstNameLink CCTContact nrSimplexContact + NTPublicGroup -> firstNameLink CCTChannel nrSimplexChannel + NRAvailable {pricing = NamePricing {registrationPrices, basePrice, minLabelLength}} + | labelLength >= minLabelLength -> + let USDCents perYear = M.findWithDefault basePrice labelLength registrationPrices + in Left $ NWAvailable NamePrice {amount = USDCents (perYear * fromIntegral years), years} + NRReserved {reservedReason = NRRCommunity} -> Left NWReservedForCommunity + _ -> Left NWNotRegistered where - setRegistration = \case - CPContactAddress pl _ -> CPContactAddress pl (Just nr) - CPGroupLink pl _ -> CPGroupLink pl (Just nr) - pl -> pl + labelLength = T.length domain + years = 2 + +setNameWarning :: NameWarning -> ConnectionPlan -> ConnectionPlan +setNameWarning w = \case + CPContactAddress CAPOwnLink _ -> CPContactAddress CAPOwnLink ownWarning + CPGroupLink p@GLPOwnLink {} _ -> CPGroupLink p ownWarning + CPContactAddress p _ -> CPContactAddress p chatWarning + CPGroupLink p _ -> CPGroupLink p chatWarning + p -> p + where + ownWarning = case w of + NWExpired e g -> Just $ NWOwnExpired e g + NWAvailable price -> Just $ NWOwnAvailable price + NWReservedForCommunity -> Just w + _ -> Nothing + chatWarning = case w of + NWExpired {} -> Just w + NWAvailable price -> Just $ NWNoLongerRegistered price + NWReservedForCommunity -> Just w + _ -> Nothing setAddressChanged :: ConnectionPlan -> ConnectionPlan setAddressChanged = \case diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 833a1ae602..5c2204d1bc 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1366,6 +1366,10 @@ Query: UPDATE connections SET smp_agent_version = ?, pq_support = ?, enable_ntfs Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) +Query: UPDATE deleted_snd_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE deleted_snd_chunk_replica_id = ? +Plan: +SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE messages SET msg_body = x'' WHERE conn_id = ? AND internal_id = ? Plan: SEARCH messages USING PRIMARY KEY (conn_id=? AND internal_id=?) @@ -1374,10 +1378,6 @@ Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ? Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) -Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? -Plan: -SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? Plan: SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5a55a15a3a..be1f16d033 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -73,9 +73,8 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, BlockingInfo (..), BlockingReason (..), NamePricing (..), NameRegistration (..), NetworkError (..), ProtocolServer (..), ProtocolTypeI, SProtocolType (..), USDCents (..), UserProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, BlockingInfo (..), BlockingReason (..), NetworkError (..), ProtocolServer (..), ProtocolTypeI, SProtocolType (..), USDCents (..), UserProtocol) import qualified Simplex.Messaging.Protocol as SMP -import Simplex.Messaging.SystemTime (roundedSeconds) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) import Simplex.Messaging.Version hiding (version) @@ -218,7 +217,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRInvitation u ccLink _ -> ttyUser u $ viewConnReqInvitation showFullLinks ccLink CRConnectionIncognitoUpdated u c customUserProfile -> ttyUser u $ viewConnectionIncognitoUpdated c customUserProfile testView CRConnectionUserChanged u c c' nu -> ttyUser u $ viewConnectionUserChanged showFullLinks u c nu c' - CRConnectionPlan u connLink _ otherSimplexName connectionPlan -> ttyUser u $ viewConnectionPlan cfg connLink connectionPlan <> otherSimplexNameNote otherSimplexName <> viewNameRegistration connectionPlan + CRConnectionPlan u connLink planSimplexName otherSimplexName connectionPlan -> ttyUser u $ viewConnectionPlan cfg connLink connectionPlan <> otherSimplexNameNote otherSimplexName <> viewNameWarning planSimplexName connectionPlan CRNewPreparedChat u (AChat _ (Chat cInfo _ _)) -> ttyUser u $ case cInfo of DirectChat ct -> [ttyContact' ct <> ": contact is prepared"] GroupChat g _ -> [ttyGroup' g <> ": group is prepared"] @@ -2235,24 +2234,28 @@ otherSimplexNameNote = \case Just ni@(SimplexNameInfo NTContact _) -> [plain $ "You can also connect to " <> shortNameInfoStr ni <> " in direct chat"] Nothing -> [] -viewNameRegistration :: ConnectionPlan -> [StyledString] -viewNameRegistration = \case - CPContactAddress CAPKnown {} nr_ -> regLine nr_ - CPContactAddress CAPOwnLink nr_ -> regLine nr_ - CPGroupLink GLPKnown {} nr_ -> regLine nr_ - CPGroupLink GLPOwnLink {} nr_ -> regLine nr_ - CPNameNotConnectable _ reg -> regLine (Just reg) +viewNameWarning :: Maybe SimplexNameInfo -> ConnectionPlan -> [StyledString] +viewNameWarning planSimplexName = \case + CPContactAddress _ (Just w) -> planNameWarning w + CPGroupLink _ (Just w) -> planNameWarning w + CPNameNotConnectable d w -> [warningStr d w] _ -> [] where - regLine = \case - Just NRRegistered {expires, graceUntil, reservedReason_} -> - ["registered" <> expiryNote expires graceUntil <> maybe "" ((", reserved: " <>) . plain . textEncode) reservedReason_] - Just NRAvailable {pricing = NamePricing {basePrice = USDCents c, minLabelLength}} -> - ["available: " <> plain (show c) <> " cents/year, min length " <> plain (show minLabelLength)] - Just NRReserved {reservedReason} -> ["reserved: " <> plain (textEncode reservedReason)] - Nothing -> [] - expiryNote expires graceUntil = maybe "" (\e -> ", expires " <> showTime e <> maybe "" ((", grace until " <>) . showTime) graceUntil) expires - showTime = plain . show . roundedSeconds + planNameWarning w = maybe [] (\SimplexNameInfo {nameDomain} -> [warningStr nameDomain w]) planSimplexName + warningStr d = \case + NWExpired e g -> name <> " expired on " <> plain (day e) <> maybe "" ((", its owner can renew it until " <>) . plain . day) g + NWOwnExpired e g -> "your " <> name <> " expired on " <> plain (day e) <> maybe "" ((", renew it before " <>) . plain . day) g + NWAvailable p -> name <> " is available: " <> priceStr p + NWNoLongerRegistered p -> name <> " is no longer registered, available: " <> priceStr p + NWOwnAvailable p -> "your " <> name <> " is no longer registered, available: " <> priceStr p + NWReservedForCommunity -> name <> " is reserved for community" + NWNotRegistered -> name <> " is not registered" + NWNoValidLink -> name <> " has no valid link" + where + name = "SimpleX name " <> plain (fullDomainName d) + priceStr NamePrice {amount = USDCents c, years} = + let (dollars, cents) = c `divMod` 100 + in plain $ "$" <> tshow dollars <> (if cents == 0 then "" else "." <> T.justifyRight 2 '0' (tshow cents)) <> " for " <> tshow years <> " years" viewConnectionPlan :: ChatConfig -> Maybe ACreatedConnLink -> ConnectionPlan -> [StyledString] viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case diff --git a/tests/ChatTests/Names.hs b/tests/ChatTests/Names.hs index 62a5a3e2ec..b4f243a506 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} @@ -8,12 +9,20 @@ import ChatTests.DBUtils import ChatTests.Groups (memberJoinChannel, prepareChannel1Relay) import ChatTests.Utils import Control.Concurrent.Async (concurrently_) +import Data.Int (Int64) +import qualified Data.Map.Strict as M import Data.Text (Text) import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (UTCTime) import NameResolver +import Simplex.Chat.Controller (ConnectionPlan (..), ContactAddressPlan (..), NamePrice (..), NameWarning (..)) +import Simplex.Chat.Library.Commands (nameLinkOrWarning, setNameWarning) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Names.Record (NameReservedReason (..)) +import Simplex.Messaging.Encoding.String (strDecode) +import Simplex.Messaging.Names.Record (NamePricing (..), NameRegistration (..), NameReservedReason (..), USDCents (..)) import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) +import Simplex.Messaging.SystemTime (RoundedSystemTime (..), roundedToUTCTime) import Test.Hspec hiding (it) chatNamesTests :: SpecWith TestParams @@ -39,6 +48,8 @@ chatNamesTests = do it "known chat, name expired" testPlanKnownNameExpired it "known chat, name moved to a new address" testPlanKnownNameAddressChanged it "known chat, name now available" testPlanKnownNameAvailable + it "known chat and own name, name without link or reserved" testPlanKnownNameReserved + it "known chat resolved over a day ago, the request failed" testPlanKnownNameResolverFailed it "known chat, resolved over a day ago or past expiry" testPlanKnownNameStale it "no local chat, resolved on every call" testPlanNameResolvedEveryCall it "own name, live" testPlanOwnNameLive @@ -126,7 +137,7 @@ testConnectByNameNotFound ps = withSmpServerAndNames $ \_reg -> enableNamesRole bob bob ##> "/c @nobody.simplex" bob <## "SimpleX name nobody.simplex: nothing to connect to" - bob <##. "available:" + bob <## "SimpleX name nobody.simplex is available: $20 for 2 years" testSetNameNotOwnAddress :: HasCallStack => TestParams -> IO () testSetNameNotOwnAddress ps = withSmpServerAndNames $ \reg -> @@ -264,7 +275,13 @@ testConnectByNameChannelAndContact ps = withSmpServerAndNames $ \reg -> bob <## "SimpleX name: #team (verified)" bob <## "use #team to send messages" bob <## "You can also connect to @team.simplex in direct chat" - bob <## "registered" + registerName reg teamName (contactNameRecord "team.simplex" (T.pack contactLink)) + withCCTransaction bob $ \db -> DB.execute_ db "UPDATE groups SET group_domain_resolved_at = datetime('now', '-2 days')" + bob ##> "/_connect plan 1 team.simplex" + bob <## "group link: known group #team" + bob <## "SimpleX name: #team (verified)" + bob <## "use #team to send messages" + bob <## "You can also connect to @team.simplex in direct chat" where teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" []) @@ -289,6 +306,9 @@ testConnectByNameContactAndChannel ps = withSmpServerAndNames $ \reg -> bob <## "contact address: ok to connect" _ <- getTermLine bob -- contact short link data (JSON, printed in test view) bob <## "You can also join channel #acme" + alice ##> "/_connect plan 1 acme.simplex" + alice <## "contact address: own address" + alice <## "You can also join channel #acme" where acmeName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "acme" []) @@ -328,6 +348,18 @@ testConnectByNameBusinessAndChannel ps = withSmpServerAndNames $ \reg -> bob ##> "/_connect plan 1 @biz.simplex resolve=never" bob <## "business address: known business #alice" bob <## "use #alice to send messages" + bob ##> "/_connect plan 1 @biz.simplex" + bob <## "business address: known business #alice" + bob <## "use #alice to send messages" + registerExpiredName reg bizName (contactAndChannelNameRecord "biz.simplex" (T.pack contactLink) (T.pack channelLink)) + bob ##> "/_connect plan 1 @biz.simplex" + bob <## "business address: known business #alice" + bob <## "use #alice to send messages" + withCCTransaction bob $ \db -> DB.execute_ db "UPDATE groups SET group_domain_resolved_at = datetime('now', '-2 days')" + bob ##> "/_connect plan 1 @biz.simplex" + bob <## "business address: known business #alice" + bob <## "use #alice to send messages" + bob <##. "SimpleX name biz.simplex expired on " -- the business's verified domain survives the handshake and is shown in group info bob ##> "/i #alice" bob <## "group ID: 1" @@ -370,14 +402,14 @@ testPlanNameExpired = withAliceName $ \reg shortLink _alice bob -> do registerExpiredName reg aliceSimplexName (contactNameRecord "alice.simplex" shortLink) bob ##> "/_connect plan 1 @alice.simplex" bob <## "SimpleX name alice.simplex: nothing to connect to" - bob <##. "registered, expires " + bob <##. "SimpleX name alice.simplex expired on " testPlanNameAvailable :: HasCallStack => TestParams -> IO () testPlanNameAvailable = withAliceName $ \reg _l _alice bob -> do registerAvailableName reg sunflower 3 bob ##> "/_connect plan 1 @sunflower.simplex" bob <## "SimpleX name sunflower.simplex: nothing to connect to" - bob <## "available: 1000 cents/year, min length 3" + bob <## "SimpleX name sunflower.simplex is available: $20 for 2 years" where sunflower = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "sunflower" []) @@ -386,7 +418,7 @@ testPlanNameReservedCommunity = withAliceName $ \reg _l _alice bob -> do registerReservedName reg privacy NRRCommunity bob ##> "/_connect plan 1 @privacy.simplex" bob <## "SimpleX name privacy.simplex: nothing to connect to" - bob <## "reserved: community" + bob <## "SimpleX name privacy.simplex is reserved for community" where privacy = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "privacy" []) @@ -395,7 +427,7 @@ testPlanNameReservedOther = withAliceName $ \reg _l _alice bob -> do registerReservedName reg acme NRRTrademark bob ##> "/_connect plan 1 @acme.simplex" bob <## "SimpleX name acme.simplex: nothing to connect to" - bob <## "reserved: trademark" + bob <## "SimpleX name acme.simplex is not registered" where acme = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "acme" []) @@ -404,7 +436,7 @@ testPlanNameNoValidLink = withAliceName $ \reg _l _alice bob -> do registerName reg boogaloo (emptyRecord "boogaloo.simplex") bob ##> "/_connect plan 1 @boogaloo.simplex" bob <## "SimpleX name boogaloo.simplex: nothing to connect to" - bob <## "registered" + bob <## "SimpleX name boogaloo.simplex has no valid link" where boogaloo = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "boogaloo" []) @@ -415,7 +447,6 @@ testPlanKnownNameLive = withAliceName $ \_reg _l alice bob -> do bob <## "contact address: known contact alice" bob <## "SimpleX name: @alice.simplex (verified)" bob <## "use @alice to send messages" - bob <## "registered" testPlanKnownNameExpired :: HasCallStack => TestParams -> IO () testPlanKnownNameExpired = withAliceName $ \reg shortLink alice bob -> do @@ -425,7 +456,7 @@ testPlanKnownNameExpired = withAliceName $ \reg shortLink alice bob -> do bob <## "contact address: known contact alice" bob <## "SimpleX name: @alice.simplex (verified)" bob <## "use @alice to send messages" - bob <##. "registered, expires " + bob <##. "SimpleX name alice.simplex expired on " testPlanKnownNameAvailable :: HasCallStack => TestParams -> IO () testPlanKnownNameAvailable = withAliceName $ \reg _l alice bob -> do @@ -435,7 +466,43 @@ testPlanKnownNameAvailable = withAliceName $ \reg _l alice bob -> do bob <## "contact address: known contact alice" bob <## "SimpleX name: @alice.simplex (verified)" bob <## "use @alice to send messages" - bob <## "available: 1000 cents/year, min length 1" + bob <## "SimpleX name alice.simplex is no longer registered, available: $20 for 2 years" + +testPlanKnownNameReserved :: HasCallStack => TestParams -> IO () +testPlanKnownNameReserved = withAliceName $ \reg _l alice bob -> do + connectBobByName alice bob + registerName reg aliceSimplexName (emptyRecord "alice.simplex") + planKnownAlice bob + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: own address" + registerReservedName reg aliceSimplexName NRRTrademark + planKnownAlice bob + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: own address" + registerReservedName reg aliceSimplexName NRRCommunity + planKnownAlice bob + bob <## "SimpleX name alice.simplex is reserved for community" + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: own address" + alice <## "SimpleX name alice.simplex is reserved for community" + where + planKnownAlice bob = do + bob ##> "/_connect plan 1 @alice.simplex resolve=all" + bob <## "contact address: known contact alice" + bob <## "SimpleX name: @alice.simplex (verified)" + bob <## "use @alice to send messages" + +testPlanKnownNameResolverFailed :: HasCallStack => TestParams -> IO () +testPlanKnownNameResolverFailed = withAliceName $ \reg _l alice bob -> do + connectBobByName alice bob + failNameResolution reg aliceSimplexName + withCCTransaction bob $ \db -> DB.execute_ db "UPDATE contact_profiles SET contact_domain_resolved_at = datetime('now', '-2 days')" + bob ##> "/_connect plan 1 @alice.simplex" + bob <## "contact address: known contact alice" + bob <## "SimpleX name: @alice.simplex (verified)" + bob <## "use @alice to send messages" + alice ##> "/_connect plan 1 @alice.simplex" + alice .<## "smpErr = NAME {nameErr = RESOLVER {resolverErr = \"HTTP 500\"}}}" testPlanKnownNameStale :: HasCallStack => TestParams -> IO () testPlanKnownNameStale = withAliceName $ \_reg _l alice bob -> do @@ -443,11 +510,9 @@ testPlanKnownNameStale = withAliceName $ \_reg _l alice bob -> do planKnownAlice bob withCCTransaction bob $ \db -> DB.execute_ db "UPDATE contact_profiles SET contact_domain_resolved_at = datetime('now', '-2 days')" planKnownAlice bob - bob <## "registered" planKnownAlice bob withCCTransaction bob $ \db -> DB.execute_ db "UPDATE contact_profiles SET contact_domain_expires_at = datetime('now', '-1 hours')" planKnownAlice bob - bob <## "registered" planKnownAlice bob where planKnownAlice bob = do @@ -464,27 +529,26 @@ testPlanNameResolvedEveryCall = withAliceName $ \reg shortLink _alice bob -> do registerExpiredName reg aliceSimplexName (contactNameRecord "alice.simplex" shortLink) bob ##> "/_connect plan 1 @alice.simplex" bob <## "SimpleX name alice.simplex: nothing to connect to" - bob <##. "registered, expires " + bob <##. "SimpleX name alice.simplex expired on " testPlanOwnNameLive :: HasCallStack => TestParams -> IO () testPlanOwnNameLive = withAliceName $ \_reg _l alice _bob -> do alice ##> "/_connect plan 1 @alice.simplex resolve=all" alice <## "contact address: own address" - alice <## "registered" testPlanOwnNameExpired :: HasCallStack => TestParams -> IO () testPlanOwnNameExpired = withAliceName $ \reg shortLink alice _bob -> do registerExpiredName reg aliceSimplexName (contactNameRecord "alice.simplex" shortLink) alice ##> "/_connect plan 1 @alice.simplex resolve=all" alice <## "contact address: own address" - alice <##. "registered, expires " + alice <##. "your SimpleX name alice.simplex expired on " testPlanOwnNameAvailable :: HasCallStack => TestParams -> IO () testPlanOwnNameAvailable = withAliceName $ \reg _l alice _bob -> do unregisterName reg aliceSimplexName alice ##> "/_connect plan 1 @alice.simplex resolve=all" alice <## "contact address: own address" - alice <## "available: 1000 cents/year, min length 1" + alice <## "your SimpleX name alice.simplex is no longer registered, available: $20 for 2 years" testPlanNameResolveNever :: HasCallStack => TestParams -> IO () testPlanNameResolveNever = withAliceName $ \_reg _l alice bob -> do @@ -511,8 +575,17 @@ testPlanKnownNameAddressChanged ps = withSmpServerAndNames $ \reg -> cath ##> "/ad" (cathLink, _) <- getContactLinks cath True registerName reg aliceSimplexName (contactNameRecord "alice.simplex" (T.pack cathLink)) + bob ##> "/_connect plan 1 @alice.simplex resolve=all" + bob <## "contact address: known contact alice" + bob <## "SimpleX name: @alice.simplex (verified)" + bob <## "use @alice to send messages" + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: own address" cath ##> "/_set domain 1 alice.simplex" cath <## "new contact address set" + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: ok to connect, address changed" + _ <- getTermLine alice bob ##> "/_connect plan 1 @alice.simplex" bob <## "contact address: known contact alice" bob <## "SimpleX name: @alice.simplex (verified)" @@ -539,3 +612,47 @@ testPlanNameResolverFailed = withAliceName $ \reg _l _alice bob -> do bob .<## "smpErr = NAME {nameErr = RESOLVER {resolverErr = \"HTTP 500\"}}}" where broken = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "broken" []) + +nameWarningTests :: Spec +nameWarningTests = do + it "warning for a name with no local chat" $ \_ -> testNameLinkOrWarning + it "warning for own name" $ \_ -> testOwnNameWarning + +testNameLinkOrWarning :: IO () +testNameLinkOrWarning = do + linkOrWarning NTContact (registered Nothing Nothing contactRecord) `shouldBe` Right contactLink + linkOrWarning NTContact (registered (Just 1100) Nothing contactRecord) `shouldBe` Right contactLink + linkOrWarning NTPublicGroup (registered Nothing Nothing channelRecord) `shouldBe` Right channelLink + linkOrWarning NTPublicGroup (registered Nothing Nothing contactRecord) `shouldBe` Left NWNoValidLink + linkOrWarning NTContact (NRRegistered Nothing Nothing (Just NRRCommunity) contactRecord) `shouldBe` Right contactLink + linkOrWarning NTContact (registered (Just 900) (Just 2000) contactRecord) `shouldBe` Left (NWExpired (utc 900) (Just $ utc 2000)) + linkOrWarning NTContact (registered (Just 900) Nothing contactRecord) `shouldBe` Left (NWExpired (utc 900) Nothing) + linkOrWarning NTContact (NRAvailable $ pricing 5 M.empty) `shouldBe` Left (NWAvailable $ NamePrice (USDCents 2000) 2) + linkOrWarning NTContact (NRAvailable $ pricing 3 (M.fromList [(5, USDCents 5000)])) `shouldBe` Left (NWAvailable $ NamePrice (USDCents 10000) 2) + linkOrWarning NTContact (NRAvailable $ pricing 6 M.empty) `shouldBe` Left NWNotRegistered + linkOrWarning NTContact (NRReserved NRRCommunity) `shouldBe` Left NWReservedForCommunity + linkOrWarning NTContact (NRReserved NRRTrademark) `shouldBe` Left NWNotRegistered + where + linkOrWarning nameType = nameLinkOrWarning (RoundedSystemTime 1000) (SimplexNameInfo nameType (SimplexDomain TLDSimplex "alice" [])) + registered expires graceUntil = NRRegistered (RoundedSystemTime <$> expires) (RoundedSystemTime <$> graceUntil) Nothing + pricing minLabelLength registrationPrices = NamePricing {registrationPrices, basePrice = USDCents 1000, minLabelLength} + contactRecord = contactNameRecord "alice.simplex" contactLinkStr + channelRecord = channelNameRecord "alice.simplex" channelLinkStr + contactLink = either error id $ strDecode $ encodeUtf8 contactLinkStr + channelLink = either error id $ strDecode $ encodeUtf8 channelLinkStr + contactLinkStr = "https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok" + channelLinkStr = "simplex:/c#AQIDBAUGBwgBAgMEBQYHCAECAwQFBgcIAQIDBAUGBwg?h=smp.simplex.im&p=5223&c=1234-w" + +testOwnNameWarning :: IO () +testOwnNameWarning = do + ownWarning (NWExpired (utc 900) Nothing) `shouldBe` Just (NWOwnExpired (utc 900) Nothing) + ownWarning (NWAvailable price) `shouldBe` Just (NWOwnAvailable price) + ownWarning NWReservedForCommunity `shouldBe` Just NWReservedForCommunity + ownWarning NWNotRegistered `shouldBe` Nothing + ownWarning NWNoValidLink `shouldBe` Nothing + where + ownWarning w = nameWarning_ $ setNameWarning w $ CPContactAddress CAPOwnLink Nothing + price = NamePrice (USDCents 2000) 2 + +utc :: Int64 -> UTCTime +utc = roundedToUTCTime . RoundedSystemTime diff --git a/tests/Test.hs b/tests/Test.hs index dd2a7b8d59..ff64b7b40f 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -15,7 +15,7 @@ import Bots.DirectoryTests import ChatClient import ChatTests import ChatTests.DBUtils -import ChatTests.Names (chatNamesTests) +import ChatTests.Names (chatNamesTests, nameWarningTests) import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System @@ -83,6 +83,7 @@ main = do describe "SimpleX chat view" viewTests describe "SimpleX chat protocol" protocolTests describe "Valid names" validNameTests + describe "Name warnings" nameWarningTests describe "Message batching" batchingTests describe "Operators" operatorTests describe "Random servers" randomServersTests