From 8028247e8755cd72d58fe56eb1185650477eb112 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 29 Sep 2026 16:32:33 +0200 Subject: [PATCH] iterate on /goal --- .../Shared/Views/ChatList/ChatListView.swift | 2 +- .../Views/NewChat/NewChatMenuButton.swift | 3 +- .../Shared/Views/NewChat/NewChatView.swift | 34 +- .../chat/simplex/common/model/ChatModel.kt | 15 - .../chat/simplex/common/model/SimpleXAPI.kt | 15 + .../common/views/chatlist/ChatListView.kt | 18 +- .../common/views/newchat/ConnectPlan.kt | 36 +- .../common/views/newchat/NewChatSheet.kt | 4 +- .../commonMain/resources/MR/base/strings.xml | 3 +- plans/2026-09-22-name-lookup-core-api.md | 2 +- plans/2026-09-28-name-warnings.md | 321 ++++++++++++++++++ src/Simplex/Chat/Controller.hs | 4 +- src/Simplex/Chat/Library/Commands.hs | 62 ++-- .../SQLite/Migrations/chat_query_plans.txt | 2 +- tests/ChatTests/Names.hs | 164 +++------ tests/NameResolver.hs | 22 +- tests/Test.hs | 3 +- 17 files changed, 476 insertions(+), 234 deletions(-) create mode 100644 plans/2026-09-28-name-warnings.md diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index fa9df3a59a..190f7d1e8b 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -920,7 +920,7 @@ struct ChatListSearchBar: View { // Row shown when the search text is a SimpleX name — in place of the list tags in the chat list, below // the search field in the new chat sheet. The @ icon marks a contact name, the tag icon a channel/other -// name; tapping hides the keyboard and connects online, and a chat found by the name stays filtered in the list. +// name; tapping hides the keyboard and connects online. struct ConnectByNameRow: View { @EnvironmentObject var theme: AppTheme var name: String diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index c3def19e8c..5d183bc67d 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -97,8 +97,7 @@ struct NewChatSheet: View { ConnectByNameRow( name: candidate, searchFocussed: $searchFocussed, - dismiss: true, - filterKnownContact: { searchChatFilteredBySimplexLink = $0.id } + dismiss: true ) } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index a749687aec..85bc708d95 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1224,7 +1224,7 @@ private func showPrepareGroupAlert( cancelTitle: openExistingChat == nil ? NSLocalizedString("Cancel", comment: "new chat action") : NSLocalizedString("Open existing chat", comment: "alert action"), confirmTitle: isChannel ? (addressChanged ? NSLocalizedString("Open new channel", comment: "new chat action") : NSLocalizedString("Open channel", comment: "new chat action")) - : (addressChanged ? NSLocalizedString("Open new group", comment: "new chat action") : NSLocalizedString("Open group", comment: "new chat action")), + : NSLocalizedString("Open group", comment: "new chat action"), secondTitle: connectOtherButton, onCancel: openExistingChat ?? { cleanup?() }, onConfirm: { @@ -1366,12 +1366,12 @@ private func showNameWarningAlert( showAlert(title, message: message, actions: { var actions: [UIAlertAction] = [] if let action { - actions.append(UIAlertAction(title: action.title, style: .default) { _ in action.handler() }) + actions.append(UIAlertAction(title: action.title, style: .default) { _ in action.handler(); cleanup?() }) } if let openExistingChat { actions.append(UIAlertAction(title: NSLocalizedString("Open existing chat", comment: "alert action"), style: .default) { _ in openExistingChat() }) } - actions.append(UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default) { _ in cleanup?() }) + actions.append(okCleanupAlertAction(cleanup: cleanup)) return actions }) } @@ -1428,28 +1428,26 @@ private func showNameWarningAlert( } } -// a name tapped in a message has no filtered list behind the alert, so 3c offers the chat the name had, if there is one private func nameChatId(_ name: SimplexNameInfo?) async -> ChatId? { guard let name else { return nil } let plan = await apiConnectPlan(connLink: name.shortStr, resolveMode: .never, inProgress: BoxedValue(false)) return await MainActor.run { knownChatId(plan) } } -// 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)?) { +private func showOtherNameAlert(_ otherSimplexName: SimplexNameInfo, connectOtherButton: String, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)?) { showAlert( String.localizedStringWithFormat( otherSimplexName.nameType == .publicGroup ? NSLocalizedString("%1$@ also leads to channel %2$@", comment: "alert title") : NSLocalizedString("%1$@ also leads to contact %2$@", comment: "alert title"), - planSimplexName.nameDomain.fullDomainName, + otherSimplexName.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) + okAlertAction ]} ) } @@ -1508,28 +1506,28 @@ func planAndConnect( } let nameDomain: SimplexDomain? = if case let .nameNotConnectable(simplexDomain, _) = connectionPlan { simplexDomain } else { planSimplexName?.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 } await MainActor.run { var openExisting: (() -> Void)? = nil - if let contact = knownContact { + switch connectionPlan { + case let .contactAddress(.known(contact), _): if ChatModel.shared.getContactChat(contact.contactId) == nil { ChatModel.shared.addChat(Chat(chatInfo: .direct(contact: contact))) } if let f = filterKnownContact { f(contact) } else { - openExisting = { openKnownContact(contact, dismiss: dismiss, cleanup: nil) } + openExisting = { openKnownContact(contact, dismiss: dismiss, cleanup: cleanup) } } - } else if let groupInfo = knownGroup { + case let .groupLink(.known(groupInfo), _): if ChatModel.shared.getGroupChat(groupInfo.groupId) == nil { ChatModel.shared.addChat(Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil))) } if let f = filterKnownGroup { f(groupInfo) } else { - openExisting = { openKnownGroup(groupInfo, dismiss: dismiss, cleanup: nil) } + openExisting = { openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) } } + default: () } showNameWarningAlert(domain: nameDomain, warning: nameWarning, openExistingChat: openExisting, cleanup: cleanup) } @@ -1682,8 +1680,8 @@ 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) + if let otherSimplexName = result.otherSimplexName, let connectOtherButton { + showOtherNameAlert(otherSimplexName, connectOtherButton: connectOtherButton, theme: theme, dismiss: dismiss, cleanup: cleanup) } } else { showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink) @@ -1775,8 +1773,8 @@ 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) + if let otherSimplexName = result.otherSimplexName, let connectOtherButton { + showOtherNameAlert(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 8f5ef06d2c..9d65606a9c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -5252,21 +5252,6 @@ enum class SimplexTLD { @SerialName("web") web } -@Serializable -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 NamePrice(val amount: Long, val years: Int) - @Serializable enum class SimplexNameType { @SerialName("publicGroup") publicGroup, 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 feb1eeb8c4..79c02eea44 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 @@ -7454,6 +7454,21 @@ sealed class ConnectionPlan { } } +@Serializable +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 NamePrice(val amount: Long, val years: Int) + @Serializable sealed class InvitationLinkPlan { @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null): InvitationLinkPlan() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index b1bb25a67a..9c46cc48c4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1021,7 +1021,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat TagsOrConnectByName(searchText, searchChatFilteredBySimplexLink, connectNameCandidate) { candidate -> TagsView(searchText) Divider() - ConnectByNameRow(candidate, searchChatFilteredBySimplexLink, close = null) + ConnectByNameRow(candidate) { connect(candidate, searchChatFilteredBySimplexLink, cleanup = null) } } ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) @@ -1030,7 +1030,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate) // top toolbar: search bar above, so on desktop the connect row goes above the tags TagsOrConnectByName(searchText, searchChatFilteredBySimplexLink, connectNameCandidate) { candidate -> - ConnectByNameRow(candidate, searchChatFilteredBySimplexLink, close = null) + ConnectByNameRow(candidate) { connect(candidate, searchChatFilteredBySimplexLink, cleanup = null) } Divider() TagsView(searchText) } @@ -1199,28 +1199,20 @@ private fun TagsOrConnectByName( val candidate = connectNameCandidate.value when { candidate == null -> TagsView(searchText) - !appPlatform.isDesktop -> ConnectByNameRow(candidate, searchChatFilteredBySimplexLink, close = null) + !appPlatform.isDesktop -> ConnectByNameRow(candidate) { connect(candidate, searchChatFilteredBySimplexLink, cleanup = null) } else -> desktopView(candidate) } } @Composable -internal fun ConnectByNameRow(name: String, searchChatFilteredBySimplexLink: MutableState>, close: (() -> Unit)?) { +internal fun ConnectByNameRow(name: String, onClick: () -> Unit) { val view = LocalMultiplatformView() Row( Modifier .fillMaxWidth() .clickable { hideKeyboard(view) - withBGApi { - planAndConnect( - chatModel.remoteHostId(), - name, - close = close, - filterKnownContact = { searchChatFilteredBySimplexLink.value = setOf(it.id) }, - filterKnownGroup = { searchChatFilteredBySimplexLink.value = setOf(it.id) }, - ) - } + onClick() } .padding(vertical = DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically 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 a2f142a581..387e9486d6 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 @@ -75,6 +75,7 @@ private fun openNameHowTo(uriHandler: UriHandler) = openBrowserAlert(SIMPLEX_NAM private const val SIMPLEX_NAMES_HOWTO_URL = "https://simplex.domains/#testing" private fun showNameWarningAlert( + rhId: Long?, domain: SimplexDomain, warning: NameWarning, openExistingChat: (() -> Unit)?, @@ -90,12 +91,12 @@ private fun showNameWarningAlert( val uriHandler = LocalUriHandler.current Column { if (action != null) { - SectionItemView({ AlertManager.privacySensitive.hideAlert(); action.second(uriHandler) }) { + SectionItemView({ dismiss(); action.second(uriHandler) }) { Text(action.first, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } if (openExistingChat != null) { - SectionItemView({ AlertManager.privacySensitive.hideAlert(); openExistingChat() }) { + SectionItemView({ dismiss(); openExistingChat() }) { Text(generalGetString(MR.strings.connect_plan_open_existing_chat), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } @@ -105,7 +106,7 @@ private fun showNameWarningAlert( } } fun alert(title: String, text: String, action: Pair Unit>? = null) { - AlertManager.privacySensitive.showAlertDialogButtonsColumn(title = title, text = text, onDismissRequest = { cleanup?.invoke() }, buttons = buttons(action)) + AlertManager.privacySensitive.showAlertDialogButtonsColumn(title = title, text = text, onDismissRequest = { cleanup?.invoke() }, hostDevice = hostDevice(rhId), buttons = buttons(action)) } val register = generalGetString(MR.strings.simplex_name_register) to { uh: UriHandler -> openNameHowTo(uh) } when (warning) { @@ -172,7 +173,7 @@ private suspend fun planAndConnectTask( connectProgressManager.stopConnectProgress() if (!inProgress.value) { return completable } if (result != null) { - val (connectionLink_, planSimplexName, otherSimplexName, connectionPlan) = result + val (connectionLink, planSimplexName, otherSimplexName, connectionPlan) = result val target = strConnectTarget(shortOrFullLink.trim()) val linkText = if (target is ConnectTarget.Link) "

${target.linkText}" else "" // the name can also resolve to the other kind; its type picks the verb, its short form the label and target @@ -203,10 +204,10 @@ private suspend fun planAndConnectTask( if (filterKnownGroup != null) filterKnownGroup(knownGroup) else openExisting = { openKnownGroup(chatModel, rhId, close, knownGroup) } } - showNameWarningAlert(nameDomain, nameWarning, openExisting, cleanup) + showNameWarningAlert(rhId, nameDomain, nameWarning, openExisting, cleanup) return completable } - val connectionLink = connectionLink_ ?: run { + if (connectionLink == null) { cleanup() return completable } @@ -288,7 +289,7 @@ private suspend fun planAndConnectTask( connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, addressChanged = connectionPlan.contactAddressPlan.addressChanged, - openExistingChat = if (filterKnownContact == null && connectionPlan.contactAddressPlan.addressChanged) openNameChat(rhId, planSimplexName, close, cleanup) else null, + openExistingChat = if (filterKnownContact == null && connectionPlan.contactAddressPlan.addressChanged) localNameChat(rhId, planSimplexName)?.let { chat -> { openChat_(chatModel, rhId, close, chat); cleanup() } } else null, close, cleanup ) @@ -350,7 +351,7 @@ private suspend fun planAndConnectTask( } if (filterKnownContact != null) { filterKnownContact(contact) - if (planSimplexName != null && otherSimplexName != null && connectOtherButton != null) showOtherNameAlert(rhId, planSimplexName, otherSimplexName, connectOtherButton, close, cleanup) + if (otherSimplexName != null && connectOtherButton != null) showOtherNameAlert(rhId, otherSimplexName, connectOtherButton, close, cleanup) } else { showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink) cleanup() @@ -386,7 +387,7 @@ private suspend fun planAndConnectTask( connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, addressChanged = connectionPlan.groupLinkPlan.addressChanged, - openExistingChat = if (filterKnownGroup == null && connectionPlan.groupLinkPlan.addressChanged) openNameChat(rhId, planSimplexName, close, cleanup) else null, + openExistingChat = if (filterKnownGroup == null && connectionPlan.groupLinkPlan.addressChanged) localNameChat(rhId, planSimplexName)?.let { chat -> { openChat_(chatModel, rhId, close, chat); cleanup() } } else null, close, cleanup ) @@ -458,7 +459,7 @@ private suspend fun planAndConnectTask( } if (filterKnownGroup != null) { filterKnownGroup(groupInfo) - if (planSimplexName != null && otherSimplexName != null && connectOtherButton != null) showOtherNameAlert(rhId, planSimplexName, otherSimplexName, connectOtherButton, close, cleanup) + if (otherSimplexName != null && connectOtherButton != null) showOtherNameAlert(rhId, otherSimplexName, connectOtherButton, close, cleanup) } else { showOpenKnownGroupAlert(chatModel, rhId, close, groupInfo, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink) cleanup() @@ -885,20 +886,17 @@ fun showPrepareContactAlert( ) } -// a name tapped in a message has no filtered list behind the alert, so the chat the name had is opened from it, if there is one -private suspend fun openNameChat(rhId: Long?, name: SimplexNameInfo?, close: (() -> Unit)?, cleanup: (() -> Unit)?): (() -> Unit)? { - val chat = name?.let { knownChatId(rhId, chatModel.controller.apiConnectPlan(rhId, it.shortStr, PlanResolveMode.PRMNever, inProgress = mutableStateOf(false))) }?.let { chatModel.getChat(it) } - return chat?.let { { openChat_(chatModel, rhId, close, it); cleanup?.invoke() } } -} +private suspend fun localNameChat(rhId: Long?, name: SimplexNameInfo?): Chat? = + name?.let { knownChatId(rhId, chatModel.controller.apiConnectPlan(rhId, it.shortStr, PlanResolveMode.PRMNever, inProgress = mutableStateOf(false))) }?.let { chatModel.getChat(it) } -// 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)?) { +private fun showOtherNameAlert(rhId: Long?, otherSimplexName: SimplexNameInfo, connectOtherButton: String, close: (() -> Unit)?, cleanup: (() -> Unit)?) { AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = String.format( generalGetString(if (otherSimplexName.nameType == SimplexNameType.publicGroup) MR.strings.simplex_name_also_leads_to_channel else MR.strings.simplex_name_also_leads_to_contact), - planSimplexName.nameDomain.fullDomainName, + otherSimplexName.nameDomain.fullDomainName, otherSimplexName.shortStr ), + hostDevice = hostDevice(rhId), buttons = { Column { SectionItemView({ @@ -949,7 +947,7 @@ fun showPrepareGroupAlert( ).joinToString("\n").ifEmpty { null }, confirmText = generalGetString( if (isChannel) (if (addressChanged) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_channel) - else (if (addressChanged) MR.strings.connect_plan_open_new_group else MR.strings.connect_plan_open_group) + else MR.strings.connect_plan_open_group ), onConfirm = { AlertManager.privacySensitive.hideAlert() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index df55adcc35..44aa3537bd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -319,7 +319,7 @@ private fun ModalData.NewChatSheetLayout( ) connectNameCandidate.value?.let { candidate -> Divider() - ConnectByNameRow(candidate, searchChatFilteredBySimplexLink, close = close) + ConnectByNameRow(candidate) { withBGApi { planAndConnect(chatModel.remoteHostId(), candidate, close = close) } } } Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) } @@ -410,7 +410,7 @@ private fun ModalData.NewChatSheetLayout( ) connectNameCandidate.value?.let { candidate -> Divider() - ConnectByNameRow(candidate, searchChatFilteredBySimplexLink, close = close) + ConnectByNameRow(candidate) { withBGApi { planAndConnect(chatModel.remoteHostId(), candidate, close = close) } } } Divider() } 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 ae6ce3a0ff..27c4c4b6e6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -19,6 +19,7 @@ Open group Open new group Open new channel + Open existing chat You are an observer You are a member You are a moderator @@ -205,7 +206,6 @@ Resolver error: %1$s No valid link The SimpleX name %1$s is registered, but it has no valid link. - Name expired %1$s expired on %2$s. Its owner can renew it until %3$s. %1$s expired on %2$s. @@ -226,7 +226,6 @@ Re-register Renew Connect to SimpleX team - Open existing chat Unconfirmed name The SimpleX name %1$s is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner. Channel temporarily unavailable diff --git a/plans/2026-09-22-name-lookup-core-api.md b/plans/2026-09-22-name-lookup-core-api.md index b5f274506c..5bc6a7341b 100644 --- a/plans/2026-09-22-name-lookup-core-api.md +++ b/plans/2026-09-22-name-lookup-core-api.md @@ -181,7 +181,7 @@ Review on 2026-09-24 reversed decision 6. Each decision below was taken by the a 1. **Storage.** `contact_profiles.contact_domain_resolved_at` and `contact_domain_expires_at`; `groups.group_domain_resolved_at` and `group_domain_expires_at` — beside `contact_domain_verified` and `group_domain_verified`, which record whether the name checked out; these record when it was last resolved and when its registration expires. `TEXT` in SQLite, `TIMESTAMPTZ` in Postgres, `_at` as in `badge_purchases.expires_at`. One migration per backend. 2. **Rule.** Under `PRMUnknown`, a known chat reached by a name is re-resolved when `resolved_at` is `NULL` or over a day old, or `expires_at` has passed. `PRMAll` always resolves; `PRMNever` never does. A name with no chat is resolved on every call and nothing is stored; the user's own address is not a chat, so it too is resolved on every call. -3. **Writes.** Wherever core sets a verification flag after a resolution, it also sets `resolved_at` to now and `expires_at` to the registration's expiry, or `NULL` when the caller does not have it — then only the one-day limit applies until the next resolution. That is `setContactDomainVerified`, `setGroupDomainVerified`, and `createPreparedContact`, which inserts a chat created by name already verified. To supply the expiry, `resolveNameRecord` returns it beside the record, for `/_verify domain` (both kinds) and `APISetPublicGroupAccess` to pass on, and `updateGroupFromLinkData` takes it from its callers, since it must not resolve. Two paths gain a write: a re-resolution confirming the name still resolves to the known chat, and one that answers the chat without a warning because the name no longer has a link of its kind or is not registered (`plans/2026-09-28-name-warnings.md`, N7 and N11). Both re-set the flag to `True`, a no-op, since only verified chats are found by name. +3. **Writes.** Wherever core sets a verification flag after a resolution, it also sets `resolved_at` to now and `expires_at` to the registration's expiry, or `NULL` when the caller does not have it — then only the one-day limit applies until the next resolution. That is `setContactDomainVerified`, `setGroupDomainVerified`, and `createPreparedContact`, which inserts a chat created by name already verified. The plan passes the expiry of the registration it resolved, and `updateGroupFromLinkData` takes it from its callers, since it must not resolve; `/_verify domain` and `APISetPublicGroupAccess` pass `NULL`. Two paths gain a write: a re-resolution confirming the name still resolves to the known chat, and one that answers the chat without a warning because the name no longer has a link of its kind or is not registered (`plans/2026-09-28-name-warnings.md`, N7 and N11). Both re-set the flag to `True`, a no-op, since only verified chats are found by name. 4. **Moved name.** When re-resolution finds the name resolves elsewhere (3c), nothing is written to the old chat. It stays stale, so each default lookup re-resolves and reports the new address; `resolve=never` still returns the old chat. 5. **Reading.** A store function reads the two columns for the one chat being planned. `LocalProfile` and `GroupInfo` do not change, so the columns never reach the UIs; loading them there would touch 19 queries in 6 store files and both types' JSON. 6. **UIs.** Both apps lose the cache — the preference, `SimplexNameResolved`, the local probe before resolving, and the invalidation in `UserAddressView` — and plan a name with the default mode. diff --git a/plans/2026-09-28-name-warnings.md b/plans/2026-09-28-name-warnings.md new file mode 100644 index 0000000000..38589340f0 --- /dev/null +++ b/plans/2026-09-28-name-warnings.md @@ -0,0 +1,321 @@ +# Name warnings in core (#7525 review, 2026-09-28) + +EP's review: the connection plan should say in core which name scenario applies, as a type with the minimal data it needs. The CLI renders it, and each app only maps its constructors to strings. The freshness check should sit where each kind's chat is found, not in one place for both. Bare names (kind unknown) and business chats should be visible in the logic. + +The canvas was reviewed against this model on 2026-09-28, story by story (§4). + +## Table of contents + +1. Executive summary +2. Terms +3. Scenarios and answers +4. Changes against today +5. The type +6. Plan logic, as a story per target +7. CLI +8. Apps +9. Canvas changes +10. What goes away +11. Decisions +12. Tests +13. Order of work +14. Done means + +## 1. Executive summary + +- **`NameWarning` replaces `NameRegistration` in the plan.** `CPContactAddress` and `CPGroupLink` have `nameWarning_ :: Maybe NameWarning`, and `CPNameNotConnectable` has `nameWarning :: NameWarning`. An app shows an alert exactly when the plan has a warning. It does not compare dates, lengths or registrations. +- **Two pure functions decide the warning.** One computes a registration's link, or the warning when nothing is local. The other maps that warning to the one for the user's own name or for a chat. Both are tested directly. +- **A typed name (`@d`, `#d`) is planned for its kind only.** + - A chat at the name's link is confirmed. + - A chat, own address or own channel at another link gives the new link's plan with `addressChanged` (3c). + - Nothing local gives the new link's plan (2a). +- **A bare name (`d`) is planned for both kinds.** + - It looks up both kinds locally and resolves the name once. + - It plans the kind that matches locally (the channel first), otherwise the kind the name has a link for. + - It offers the other kind when the name has a link of it that the user does not have. +- **The local lookup of each kind also returns whether the chat's name was resolved within a day.** It reads this where it finds the chat: the contact's resolution for a contact, the group's for a business chat or channel. No function checks freshness for both kinds. +- **Accepted:** + - An answer for a chat can be up to a day old. + - Removing the link of a chat's kind from the name is not reported. + - Reservations for reasons other than community, and label lengths, are not reported for a chat or the own name. + +## 2. Terms + +- **name:** `d`, e.g. `bakery.simplex`. It is typed as `@d` (contact name), `#d` (channel name), or bare `d`. +- **kind:** contact or channel. `@d` has the contact kind, `#d` the channel kind. **The other kind** is the channel for `@d` and the contact for `#d`. +- **the name's link of a kind:** the first entry in the registry record's list for that kind (`nrSimplexContact`, `nrSimplexChannel`) that parses as a short link of that kind's type. Entries that do not parse are ignored. **No link of a kind:** no such entry. +- **links of local things:** + - **a chat's link:** the short link stored when the user connected or joined (`conn_short_link_to_connect`); + - **the own address's or own channel's link:** its `short_link_contact`; + - **the same link:** equal after the server address is normalized (`serverShortLink`); + - **a new link:** the name's link differs from the local one; + - **leads to:** "the name leads to X" means the name's link of X's kind is X's link. +- **registry answers:** + - **registered:** the registry answers `NRRegistered`; + - **live:** registered, and `expires` is absent or not earlier than now; + - **expired:** registered, and `expires` is earlier than now, whether or not the grace period (`graceUntil`) has passed; + - **available:** `NRAvailable`, and the label (the part before the TLD, without subnames) has at least `minLabelLength` characters; + - **reserved for community:** `NRReserved community`; + - **not registered:** `NRReserved` for another reason (internal, trademark, unknown), or `NRAvailable` with a shorter label; + - **request failed:** the resolver request returns an error (network, server or resolver). +- **address:** a SimpleX contact address, i.e. a short link of the contact type, including business addresses. +- **own address:** the user's contact address (not a group link). It counts for `d` when the user's profile claims `d`. +- **channel:** a public group of the channel type, joined through a short link of the channel type. +- **own channel:** a group the user has the group link for, whose profile claims `d`. +- **chat** (for a name, of a kind), not counting the own address or channel: + - contact kind: a contact that is not deleted, or a business chat the user is still a member of; + - channel kind: a joined channel (not a business chat) the user is still a member of. + + In both cases the name `d` is verified, and a short link is stored. A contact connected by a one-time invitation, or with an unverified name, is not a chat for the name. +- **local:** a chat, the own address or the own channel, of the kind, for the name. **Nothing local:** none of them. +- **fresh:** the chat's name was resolved within the last 24 hours, and the expiry stored with it has not passed. The own address and channel are never fresh, so an owner sees a change on every lookup. +- **from search, from a message:** the canvas entry points 1a and 1b. + +## 3. Scenarios and answers + +From `plans/sketches/2026-09-18-names-lookup-flows.excalidraw`. + +**A typed name `@d` or `#d`, of kind K** + +| Name | Local of kind K | Answer | Warning | Screen | +|---|---|---|---|---| +| live, link L | nothing | L's plan | — | 2a | +| live, link L | chat at L | the chat, confirmed | — | 3a | +| live, link L | chat at another link | L's plan, `addressChanged` | — | 3c | +| live, link L | own at L | own | — | 4a | +| live, link L | own at another link | L's plan, `addressChanged` | — | 3c | +| live, link L whose profile does not claim `d` | nothing | error `SDEUnknownDomain` | — | 2g, 4b | +| live, link L whose profile does not claim `d` | chat or own | the local one | — | 3a, 4a | +| live, no link of kind K | nothing | not connectable | `NWNoValidLink` | 2f | +| live, no link of kind K | chat or own | the local one | — | 3a, 4a | +| expired | nothing | not connectable | `NWExpired` | 2b | +| expired | chat | the chat | `NWExpired` | 3b, 1d | +| expired | own | own | `NWOwnExpired` | 4c | +| available | nothing | not connectable | `NWAvailable` | 2c | +| available | chat | the chat | `NWNoLongerRegistered` | 3d | +| available | own | own | `NWOwnAvailable` | 4d | +| reserved for community | nothing or chat | not connectable, or the chat | `NWReservedForCommunity` | 2d, 3d | +| reserved for community | own | own | `NWReservedForCommunity` (N13) | 2d's alert | +| not registered | nothing | not connectable | `NWNotRegistered` | 2e | +| not registered | chat or own | the local one | — | 3a, 4a | +| request failed | chat | the chat | — | 3a | +| request failed | own or nothing | error | — | 2h | +| not asked: a fresh chat in the default mode, or `resolve=never` | chat or own | the local one | — | 3a, 4a | + +A typed name has no `otherSimplexName`: `@` and `#` state the user's intent. + +**A bare name `d`** + +The match is what the channel kind's local lookup found, else what the contact kind's found. + +| Local | Answer | +|---|---| +| `resolve=never` | the match, or `CENotResolvedLocally` | +| default mode, and the match is a fresh chat | the chat, from the store | +| any other match: own, a chat that is not fresh, or `resolve=all` | the resolved registration, planned as the typed name of the match's kind (the rows above) | +| nothing local | the channel's plan if the name has a live channel link, falling back to the contact kind if it fails; otherwise the contact kind's plan if the name has a live contact link; otherwise not connectable with the "nothing" row's warning; a failed request is an error | + +When the name is resolved and has a live link of the kind not planned, `otherSimplexName` is that kind's name, unless the user has a chat, own address or own channel of that kind at that link. The plan's screen shows it: +- the second button of 2a, 3c and 4a; +- 3e for a chat (new). + +## 4. Changes against today + +"Today" is #7525 as of `b78d76ae6`, with its apps. Prices are the registry's per-year price for the label's length, times 2 years. + +| # | Story | Today | New | +|---|---|---|---| +| 1 | The name is live and also reserved for community, whatever is local | the community alert instead of the plan | the plan, as for any live name | +| 4 | Bare name; a chat that is not fresh; the name also leads to the other kind at a link the user has no chat at (e.g. channel `#bakery`, the name now has only a contact link; or contact `@bakery`, the name has both) | the channel's plan if the name has a channel link, else the contact's; the local chat is shown only as the other kind's button, if at all | the local chat, and 3e: "bakery.simplex also leads to …", with Join channel or Connect | +| 7 | A chat; the name leads to a new link whose profile does not claim the name | the "Unconfirmed name" error alert | the chat, no alert | +| 8 | A chat that is not fresh; the request fails | the "SimpleX name error" alert | the chat, no alert | +| 9 | A chat; the name leads to a new link; from a message | 3c with Open new chat, Cancel | 3c with Open new chat, Open existing chat (opens the chat that the name's local lookup finds), and no Cancel | +| 10 | A chat; the name is available | "Name no longer registered", "from $X per year" | the same alert, "$Y for 2 years" | +| 11 | Own address or channel; the name leads to another link | "Connect to yourself?", or the own channel | 3c: "alice.simplex now leads to a new address", as for a chat, including 9 | +| 14 | Own; the name is available | "Your name has expired", "from $X per year" | the same alert, "$Y for 2 years" | +| 15 | Bare name; own address; the name also has a channel link the user has no chat at | the channel's join sheet | "Connect to yourself?" (4a) with Join channel | + +Unchanged, and checked in the review: +- typed names never offer the other kind; +- nothing local and no link of kind K: 2f, with no other kind; +- a chat or own, and the name is not registered: no warning; +- own, no link of its kind: no warning; +- own and the request failed: the error alert. + +## 5. The type + +```haskell +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 + +data NamePrice = NamePrice {amount :: USDCents, years :: Int} +``` + +- `expiredAt` is present whenever a name is expired, because a registration without `expires` counts as live. A missing `graceUntil` is the canvas's dateless variant. +- `NamePrice` is the price of the 2-year term (N3): the registry's per-year price for the label's length (or its base price), times `years = 2`. +- The domain is not repeated. It is `planSimplexName`, or `simplexDomain` on `CPNameNotConnectable`. + +**Deciding the warning.** Two pure functions, next to `setAddressChanged`: + +```haskell +nameLinkOrWarning :: SystemSeconds -> SimplexNameInfo -> NameRegistration -> Either NameWarning ShortLinkContact +setNameWarning :: NameWarning -> ConnectionPlan -> ConnectionPlan +``` + +- **`nameLinkOrWarning`** returns the name's link of the kind, or the warning for nothing local (the "nothing" rows of §3). The label length check moves from the apps into it. +- **`setNameWarning`** sets the warning for the local plan, as one `case` over the plan constructors: + - own: `NWExpired` becomes `NWOwnExpired`, `NWAvailable` becomes `NWOwnAvailable`, and `NWReservedForCommunity` stays (N13); the others become no warning; + - a chat: `NWAvailable` becomes `NWNoLongerRegistered`, and `NWExpired` and `NWReservedForCommunity` stay; the others become no warning. + +## 6. Plan logic, as a story per target + +**The local lookups.** Two functions, one per kind, moved from the kind branches to the `where` of the `SCMContact` equation, so the bare name path can use them: +- **the contact kind:** the own address, else a contact, else a business chat (`getGroupToConnect`, which matches `business_chat IS NOT NULL` for `@` names); +- **the channel kind:** the own channel, else a joined channel. + +Each returns the plan for what it finds, and whether it is fresh. It reads the contact's resolution for a contact, and the group's for a business chat or channel. The own address and channel are not fresh. + +**A typed name `@d` or `#d`** + +1. Look up the name's kind locally. +2. With `resolve=never`, answer with what was found, or fail with `CENotResolvedLocally`. +3. In the default mode, answer with a fresh chat. +4. Resolve the registration, unless the bare name path passed it. If the request fails, answer with a chat, or fail. +5. `nameLinkOrWarning` gives either: + - **a link L:** + - own at L is answered as found, and a chat at L is confirmed (`setContactDomainVerified`, `setGroupDomainVerified`, or the channel's refresh from its link data); + - otherwise the plan for L, with `addressChanged` if something was local; if L's profile does not claim the name and something was local, answer with it instead; + - **a warning:** the local plan with `setNameWarning`, or `CPNameNotConnectable d` with the warning. + +**A link target** keeps today's steps: a local chat is answered, and `resolve=all` refreshes a known channel from its link data. + +**A bare name `d`** + +1. Look up both kinds locally. The match is what the channel kind's lookup found, else what the contact kind's found. +2. With `resolve=never`, answer with the match, or fail with `CENotResolvedLocally`. +3. In the default mode, answer with the match if it is a fresh chat. +4. Resolve the registration once. If the request fails, answer with the match if it is a chat, or fail. +5. Plan the kind: + - the match's kind, if there is a match; + - otherwise the channel if the name has a live channel link, falling back to the contact kind if that fails; + - otherwise the contact kind if the name has a live contact link. + + It is planned as the typed name, passing the registration. With no kind to plan, answer `CPNameNotConnectable d` with the "nothing" warning. +6. Set `otherSimplexName` to the other kind's name if the name has a live link of it, unless the local lookup of that kind found something at that link. + +## 7. CLI + +`viewNameWarning` prints one line after the plan, replacing today's `registered …` / `available …` / `reserved …` lines: + +- `SimpleX name bakery.simplex expired on 2027-06-24, its owner can renew it until 2027-09-22` +- `your SimpleX name alice.simplex expired on 2027-06-24, renew it before 2027-09-22` +- `SimpleX name sunflower.simplex is available: $20 for 2 years` +- `SimpleX name bakery.simplex is no longer registered, available: $20 for 2 years` +- `your SimpleX name alice.simplex is no longer registered, available: $20 for 2 years` +- `SimpleX name privacy.simplex is reserved for community` +- `SimpleX name acme.simplex is not registered` +- `SimpleX name boogaloo.simplex has no valid link` + +A missing `graceUntil` drops the second clause of the expiry lines. `otherSimplexNameNote` is unchanged. The prices are the test registry's ($10 per year). + +## 8. Apps + +- **Alert.** `showNameRegistrationAlert` becomes a plain `case` from `NameWarning` to title, message and action (Renew, Register, Re-register, Connect to SimpleX team). It keeps "Open existing chat" when the plan has a chat (1d). +- **Flow.** `planAndConnect` shows the alert when the plan has a warning. Otherwise: + - a plan for a chat (`CAPKnown`, `GLPKnown`) with `otherSimplexName` shows 3e (N14); + - every other plan proceeds as today. + + There is no `isOwn`, `notConnectable`, `hasLocalChat`, expiry or length logic in either app. +- **3c from a message.** The buttons are Open new chat (Open new channel) and Open existing chat, with no Cancel. Open existing chat is offered only when the name's local lookup (`resolve=never`) finds a chat, so the own address gets Cancel (N20). +- **Name search.** The "Connect to" row passes the filters, as a pasted link does (N17). +- **Types.** Kotlin and Swift get `NameWarning` and `NamePrice` in place of `NameRegistration` and `NamePricing`. The hand-written Swift decoder for `NameRegistration` goes away: `NameWarning` is chat's own type and derives like its neighbours. +- **Strings.** The price strings change from "from %s per year" to "%s for %d years". 3e needs a title, a message and its buttons. + +## 9. Canvas changes + +- **2a, 3c, 4a:** the other kind's button is shown for bare names only. +- **3c:** also applies to the own address and channel. From a message, it shows Open new chat and Open existing chat, with no Cancel. +- **3e (new):** a bare name matches a chat, and the name also leads to the other kind: "bakery.simplex also leads to channel #bakery.simplex", Join channel, OK. From a message it also shows Open existing chat. +- **Prices:** "$X for 2 years", computed from the registry's price. The amounts on the canvas are examples. +- **3a:** unchanged: "Still leads to your chat, or not found, no valid link, another name, or the request failed" matches §3. + +## 10. What goes away + +- `nameRegistration_` on `CPContactAddress`/`CPGroupLink`, `nameRegistration` on `CPNameNotConnectable`, and `setPlanRegistration`. +- The `PRMUnknown` equation with `resolvedRecently`. +- The pre-resolve branch of `CTShortContact`, `resolveNameLink`, `nameHasLink` and `nameExpired`. +- `viewNameRegistration`, replaced by `viewNameWarning`. +- In both apps: the decisions in `showNameRegistrationAlert`, `NameRegistration.expired`, `reservedForCommunity`, `centsPerYear`, `nameCentsPerYear`, and the Swift `NameRegistration` decoder. + +## 11. Decisions + +Decided: + +| # | Question | Answer | +|---|---|---| +| N1 | Where the warning is | `nameWarning_ :: Maybe NameWarning` on `CPContactAddress`/`CPGroupLink`, `nameWarning :: NameWarning` on `CPNameNotConnectable` | +| N2 | Own and chat variants | separate constructors | +| N3 | Price | the registry's per-year price for the label's length, times 2 years | +| N4 | A live name with `reservedReason_ = community` | no warning | +| N5 | 3c | `addressChanged :: Bool` stays | +| N6 | Answers up to a day old for a chat | accepted | +| N7 | The name no longer has a link of a chat's or own's kind | not reported | +| N8 | The other kind | offered for bare names only, unless the user has a chat, own address or own channel of that kind at its link | +| N9 | Own address or channel at another link than the name's | 3c, as for a chat | +| N10 | 3c from a message | Open new chat, and Open existing chat (the chat found by the name's local lookup), no Cancel | +| N11 | Not registered (reserved for another reason, or too short), with a chat or own | not reported | +| N12 | The new link does not claim the name, with a chat or own | the local one, no alert | +| N15 | The request failed | with a chat: the chat, no alert; with own or nothing: the error alert | +| N16 | Where the bare name's lookups and freshness are | the two local lookups move to the equation's `where` and return freshness; the bare name path uses both | +| N17 | Name search ("Connect to" row) | behaves as the canvas's search (1c): it passes the filters, so found chats stay filtered, dismissing keeps the search, 3c shows Cancel and 3e is the alert | +| N18 | `/c` when the name moved (3c) or the own name has a warning | the plan is shown instead of connecting (`connectionPlanProceed`) | +| N19 | A chat, and the name's link data cannot be fetched | the chat, no alert, as N15 | +| N20 | 3c for the own address from a message | Cancel: there is no chat to open | +| N21 | 3c for a channel | "… now leads to a new channel." in the information line; Open new channel, and Open existing chat from a message | +| N22 | Bare name, the channel failed and the contact kind is planned | the channel is not offered as the other kind | +| N23 | A chat answered without a warning (N7, N11) | stored as resolved, so it is not re-resolved for a day | +| N24 | Expired without a grace date | "expired on ", without a renew clause, as the CLI | +| N25 | Dates in the alerts | one long localized format in both apps | +| N26 | Bot clients | `connLink` is optional in Python and Node; `resolve=allGroups` still parses | +| N13 | Own name reserved for community after its registration ended | `NWReservedForCommunity`, 2d's alert | +| N14 | What shows 3e | the app, for a chat's plan with `otherSimplexName` | + +## 12. Tests + +- **Unit tests:** + - `nameLinkOrWarning`: one per "nothing" row of §3, plus the dateless, too-short, 2-year price and live-community cases; + - `setNameWarning` for the own address (`CPContactAddress CAPOwnLink`). + + The chat rows need a `Contact` or `GroupInfo`, and are covered by CLI tests. +- **CLI tests:** "connection plan: the name lookup answers" asserts the warning lines instead of the registration lines. Tests are added for: + - §4 stories 4, 7, 8, 11 and 15, and 10 and 14 through the price in the warning lines; story 1 is a unit test; + - a chat with a name reserved for community (3d); + - a chat and own with a name not registered (no warning); + - a business chat, fresh and not fresh; + - a bare name with a fresh chat (no resolution). +- **Unchanged:** the freshness tests (§9 of the lookup plan) pass as they are. + +## 13. Order of work + +1. Core: + - `NameWarning`, `NamePrice`, `nameLinkOrWarning` and `setNameWarning`, with their unit tests; + - the local lookups; + - the typed name and bare name paths; + - the View; + - the CLI tests. +2. Regenerate the bot API types. +3. Kotlin, then Swift: the `case`, 3e, 3c from a message, the strings. +4. Update the lookup plan (`plans/2026-09-22-name-lookup-core-api.md`) §2–§4, and the canvas (§9). + +## 14. Done means + +- No app reads a `NameRegistration`, compares dates or lengths, or decides whether to alert. +- The typed name and bare name paths read as §6. Freshness is read only by the local lookups. +- Every row of §3, and every story of §4 except 9 (apps only), has a unit test or a CLI assertion. diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index f3ff1796e4..1e0be89129 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -710,7 +710,7 @@ data ChatCommand data PlanResolveMode = PRMAll -- always resolve, also known chats - | PRMUnknown -- only resolve if chat is unknown (default) + | PRMUnknown -- resolve unknown chats, and names of known chats not resolved within a day (default) | PRMNever -- do not resolve links and names, only do local search deriving (Eq, Show) @@ -1490,7 +1490,7 @@ data ChatError | ChatErrorRemoteHost {rhKey :: RHKey, remoteHostError :: RemoteHostError} deriving (Show, Exception) --- why a resolved SimpleX name could not be used (the name itself resolved; in a connection plan, a name with nothing to connect is CPNameNotConnectable) +-- why a resolved SimpleX name could not be used (the name itself resolved; an unregistered name is the agent's NAME NOT_FOUND) data SimplexDomainError = SDENoValidLink -- the name's record has no usable contact/channel link | SDEUnknownDomain -- the resolved link's profile has no name, or a different name diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c549b0fe16..e66d2bae17 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1606,7 +1606,7 @@ processChatCommand cxt nm = \case UserContactLink {shortLinkDataSet, connLinkContact = CCLink _ sl_} <- withFastStore (`getUserAddress` user) case sl_ of Just sl | shortLinkDataSet -> do - (NameRecord {nrSimplexContact}, _) <- resolveNameRecord user nm domain + NameRecord {nrSimplexContact} <- resolveNameRecord user nm domain unless (nameResolvesTo sl nrSimplexContact) $ throwChatError $ CESimplexDomainNotReady domain SDENoValidLink pure $ Just (CLShort sl) _ -> throwCmdError "create the address short link and add it to name" @@ -2411,21 +2411,21 @@ processChatCommand cxt nm = \case let connLink_ = preparedContact >>= \PreparedContact {connLinkToConnect = ACCL m (CCLink _ sLnk_)} -> ACSL m <$> sLnk_ domain <- maybe (throwCmdError "contact has no name to verify") pure contactDomain (verified, reason) <- verifyEntityDomain user nm NTContact domain connLink_ - ct' <- maybe (pure ct) (\(v, expiresAt) -> withFastStore' $ \db -> setContactDomainVerified db user ct v expiresAt) verified + ct' <- maybe (pure ct) (\v -> withFastStore' $ \db -> setContactDomainVerified db user ct v Nothing) verified pure $ CRContactDomainVerified user ct' reason APIVerifyGroupDomain groupId -> withUser $ \user -> do g@GroupInfo {groupProfile = GroupProfile {publicGroup}} <- withFastStore $ \db -> getGroupInfo db cxt user groupId PublicGroupProfile {groupLink, publicGroupAccess} <- maybe (throwCmdError "not a public group") pure publicGroup claim <- maybe (throwCmdError "group has no name to verify") pure $ publicGroupAccess >>= groupDomainClaim -- checks the profile link, not the link we joined through (which may have rotated) - (verified, reason, expiresAt) <- + (verified, reason) <- tryAllErrors (resolveNameRecord user nm (claimDomain claim)) >>= \case - Right (NameRecord {nrSimplexChannel}, expiresAt) - | nameResolvesTo groupLink nrSimplexChannel -> pure (True, Nothing, expiresAt) - | otherwise -> pure (False, Just "the name does not resolve to the link in the group profile", expiresAt) - Left (ChatErrorAgent {agentError = SMP _ (NAME SMP.NOT_FOUND)}) -> pure (False, Just "the name is not registered", Nothing) + Right NameRecord {nrSimplexChannel} + | nameResolvesTo groupLink nrSimplexChannel -> pure (True, Nothing) + | otherwise -> pure (False, Just "the name does not resolve to the link in the group profile") + Left (ChatErrorAgent {agentError = SMP _ (NAME SMP.NOT_FOUND)}) -> pure (False, Just "the name is not registered") Left e -> throwError e - g' <- withFastStore' $ \db -> setGroupDomainVerified db user g verified expiresAt + g' <- withFastStore' $ \db -> setGroupDomainVerified db user g verified Nothing pure $ CRGroupDomainVerified user g' reason APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do ct@Contact {profile = LocalProfile {contactLink}, groupDirectInv} <- withFastStore $ \db -> getContact db cxt user contactId @@ -3280,7 +3280,7 @@ processChatCommand cxt nm = \case processChatCommand cxt nm $ APIListGroups userId (contactId' <$> ct_) search_ APIUpdateGroupProfile groupId p' -> withUser $ \user -> do gInfo <- withFastStore $ \db -> getGroupInfoKeys db cxt user groupId - runUpdateGroupProfile user gInfo p' Nothing + runUpdateGroupProfile user gInfo p' False UpdateGroupNames gName GroupProfile {displayName, fullName, shortDescr} -> updateGroupProfileByName gName $ \p -> p {displayName, fullName, shortDescr} ShowGroupProfile gName -> withUser $ \user -> @@ -3294,11 +3294,11 @@ processChatCommand cxt nm = \case case publicGroup of Just pg@PublicGroupProfile {groupLink, publicGroupAccess = existingAccess} -> do let domainChanged = (claimDomain <$> newClaim) /= (claimDomain <$> (existingAccess >>= groupDomainClaim)) - verifiedExpiry_ <- forM (if domainChanged then claimDomain <$> newClaim else Nothing) $ \newDomain -> do - (NameRecord {nrSimplexChannel}, expiresAt) <- resolveNameRecord user nm newDomain - unless (nameResolvesTo groupLink nrSimplexChannel) $ throwChatError $ CESimplexDomainNotReady newDomain SDENoValidLink - pure expiresAt - runUpdateGroupProfile user gInfo p {publicGroup = Just pg {publicGroupAccess = Just access}} verifiedExpiry_ + forM_ (claimDomain <$> newClaim) $ \newDomain -> + when domainChanged $ do + NameRecord {nrSimplexChannel} <- resolveNameRecord user nm newDomain + unless (nameResolvesTo groupLink nrSimplexChannel) $ throwChatError $ CESimplexDomainNotReady newDomain SDENoValidLink + runUpdateGroupProfile user gInfo p {publicGroup = Just pg {publicGroupAccess = Just access}} (isJust newClaim && domainChanged) Nothing -> throwChatError $ CECommandError "not a public group" APICreateGroupLink groupId mRole -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do gInfo@GroupInfo {groupProfile} <- withFastStore $ \db -> getGroupInfo db cxt user groupId @@ -4099,8 +4099,8 @@ processChatCommand cxt nm = \case void (sendDirectContactMessage user ct' $ XInfo p Nothing) `catchAllErrors` eToView lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' - runUpdateGroupProfile :: User -> GroupInfoKeys -> GroupProfile -> Maybe (Maybe UTCTime) -> CM ChatResponse - runUpdateGroupProfile user (GIK gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} gks) p'@GroupProfile {displayName = n', image = img', memberAdmission = ma'} verifiedExpiry_ = do + runUpdateGroupProfile :: User -> GroupInfoKeys -> GroupProfile -> Bool -> CM ChatResponse + runUpdateGroupProfile user (GIK gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} gks) p'@GroupProfile {displayName = n', image = img', memberAdmission = ma'} domainVerified = do assertUserGroupRole gInfo GROwner when (n /= n') $ checkValidName n' checkProfileImageSize img' @@ -4109,7 +4109,7 @@ processChatCommand cxt nm = \case -- updateGroupProfile clears domain verification; re-set it when the caller already re-resolved the name gInfo' <- withStore $ \db -> do g <- updateGroupProfile db user gInfo p' - maybe (pure g) (liftIO . setGroupDomainVerified db user g True) verifiedExpiry_ + if domainVerified then liftIO $ setGroupDomainVerified db user g True Nothing else pure g msg <- case businessChat of Just BusinessChatInfo {businessId} -> do ms <- withStore' $ \db -> getGroupMembers db cxt user gInfo' @@ -4202,7 +4202,7 @@ processChatCommand cxt nm = \case applicable = if channel then groupFeatureInChannel feature else groupFeatureInRegularGroup feature unless applicable $ throwCmdError $ T.unpack (groupFeatureNameText feature) <> " is not available in " <> (if channel then "channels" else "groups") - runUpdateGroupProfile user gInfo (update p) Nothing + runUpdateGroupProfile user gInfo (update p) False withCurrentCall :: ContactId -> (User -> Contact -> Call -> CM (Maybe Call)) -> CM ChatResponse withCurrentCall ctId action = do (user, ct) <- withStore $ \db -> do @@ -4506,10 +4506,12 @@ processChatCommand cxt nm = \case 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 + Just r@(_, p) + | resolveMode == PRMAll && knownChat p -> confirmKnown Nothing l r + | otherwise -> pure r + Nothing -> do when (resolveMode == PRMNever) $ throwChatError CENotResolvedLocally - linkPlan Nothing l + resolvedPlan 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 @@ -5138,10 +5140,10 @@ resolveNameRegistration user nm domain = registration <$> withAgent (\a -> resolveSimplexName a nm (aUserId user) domain) -- the resolver now also reports names that are not registered, which stay the agent's NAME NOT_FOUND -resolveNameRecord :: User -> NetworkRequestMode -> SimplexDomain -> CM (NameRecord, Maybe UTCTime) +resolveNameRecord :: User -> NetworkRequestMode -> SimplexDomain -> CM NameRecord resolveNameRecord user nm domain = resolveNameRegistration user nm domain >>= \case - reg@NRRegistered {nameRecord} -> pure (nameRecord, nameExpiresAt reg) + NRRegistered {nameRecord} -> pure nameRecord _ -> throwError $ chatErrorAgent $ SMP "" (NAME SMP.NOT_FOUND) nameExpiresAt :: NameRegistration -> Maybe UTCTime @@ -5189,24 +5191,24 @@ setNameWarning w = \case setAddressChanged :: ConnectionPlan -> ConnectionPlan setAddressChanged = \case - CPContactAddress (CAPOk cld ov _) nr -> CPContactAddress (CAPOk cld ov True) nr - CPGroupLink (GLPOk li gld ov _) nr -> CPGroupLink (GLPOk li gld ov True) nr + CPContactAddress (CAPOk cld ov _) w_ -> CPContactAddress (CAPOk cld ov True) w_ + CPGroupLink (GLPOk li gld ov _) w_ -> CPGroupLink (GLPOk li gld ov True) w_ p -> p -verifyEntityDomain :: User -> NetworkRequestMode -> SimplexNameType -> SimplexDomainClaim -> Maybe AConnShortLink -> CM (Maybe (Bool, Maybe UTCTime), Maybe Text) +verifyEntityDomain :: User -> NetworkRequestMode -> SimplexNameType -> SimplexDomainClaim -> Maybe AConnShortLink -> CM (Maybe Bool, Maybe Text) verifyEntityDomain user nm nameType SimplexDomainClaim {domain = StrJSON domain, proof = proof_} connLink_ = case (proof_, connLink_) of (Nothing, _) -> pure (Nothing, Just "no name proof to verify") (_, Nothing) -> pure (Nothing, Just "no connection link to check the name against") (Just proof, Just (ACSL SCMContact profileSLnk)) -> do - (NameRecord {nrSimplexContact, nrSimplexChannel}, expiresAt) <- resolveNameRecord user nm domain + NameRecord {nrSimplexContact, nrSimplexChannel} <- resolveNameRecord user nm domain let resolvedLinks = case nameType of NTContact -> nrSimplexContact NTPublicGroup -> nrSimplexChannel if not (nameResolvesTo profileSLnk resolvedLinks) - then pure (Just (False, expiresAt), Just "the name does not resolve to this address") + then pure (Just False, Just "the name does not resolve to this address") else do ok <- verifyDomainProof proof profileSLnk - pure (Just (ok, expiresAt), if ok then Nothing else Just "the name proof was not signed by this address's owner") + pure (Just ok, if ok then Nothing else Just "the name proof was not signed by this address's owner") (Just _, Just _) -> pure (Nothing, Just "unexpected connection link type for name verification") where verifyDomainProof :: SimplexDomainProof -> ShortLinkContact -> CM Bool @@ -5827,7 +5829,7 @@ sendServiceRequestBytes nm user sendTarget requestTimeout signKey request = do _ -> throwCmdError "service request target must be a contact" CTDomain d -> resolveDomain d resolveDomain d = do - (nr, _) <- resolveNameRecord user nm d + nr <- resolveNameRecord user nm d case firstNameLink CCTContact (nrSimplexContact nr) of Just sLnk -> resolveShortLink sLnk Nothing -> throwChatError $ CESimplexDomainNotReady d SDENoValidLink diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 14e6e93bd7..abb01e3f66 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -7883,7 +7883,7 @@ Query: UPDATE contact_profiles SET contact_domain = ?, updated_at = ? WHERE user Plan: SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE contact_profiles SET contact_domain_expires_at = datetime('now', '-1 hours') +Query: UPDATE contact_profiles SET contact_domain_resolved_at = datetime('now'), contact_domain_expires_at = datetime('now', '-1 hours') Plan: SCAN contact_profiles diff --git a/tests/ChatTests/Names.hs b/tests/ChatTests/Names.hs index 0796a6d142..71a05f837a 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -30,7 +30,7 @@ chatNamesTests = do it "connect by resolved name" testConnectByName it "connect by name not claimed in link profile is rejected" testConnectByNameNotClaimed it "connect by name to a known contact not claimed in profile is rejected" testConnectByNameKnownContactNotClaimed - it "connect by unregistered name reports the registration" testConnectByNameNotFound + it "connect by unregistered name reports it is available" testConnectByNameNotFound it "set name not resolving to own address is rejected" testSetNameNotOwnAddress it "channel name is not verified just by joining via link" testChannelDomainLinkJoinUnverified it "verify channel name, fail on re-point, retain status on refresh" testChannelDomainVerify @@ -39,8 +39,6 @@ chatNamesTests = do it "connect by name resolving to direct contact (primary) and channel" testConnectByNameContactAndChannel it "connect by name resolving to business (primary) and channel" testConnectByNameBusinessAndChannel describe "connection plan: the name lookup answers" $ do - it "expired, no local chat" testPlanNameExpired - it "available, no local chat" testPlanNameAvailable it "reserved for community" testPlanNameReservedCommunity it "reserved for another reason" testPlanNameReservedOther it "registered with no usable link" testPlanNameNoValidLink @@ -49,9 +47,8 @@ chatNamesTests = do 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, the request failed" testPlanKnownNameResolverFailed it "known chat, the name's new link cannot be fetched" testPlanKnownNameLinkFailed - it "live name also reserved for community" testPlanNameLiveCommunity it "own channel expired, joined channel moved to a new channel" testPlanChannelNameMoved it "known chat, resolved over a day ago or past expiry" testPlanKnownNameStale it "no local chat, resolved on every call" testPlanNameResolvedEveryCall @@ -60,6 +57,7 @@ chatNamesTests = do it "own name, now available" testPlanOwnNameAvailable it "the request failed" testPlanNameResolverFailed it "resolve=never: local hit and miss" testPlanNameResolveNever + describe "name warnings" nameWarningTests testConnectByName :: HasCallStack => TestParams -> IO () testConnectByName ps = withSmpServerAndNames $ \reg -> @@ -172,7 +170,7 @@ testChannelDomainLinkJoinUnverified ps = withSmpServerAndNames $ \reg -> cath <## "updated public group access: domain=team.simplex" memberJoinChannel "team" [cath] [alice] shortLink fullLink bob -- a link-data refresh must not mark the self-claimed name verified - bob ##> ("/_connect plan 1 " <> shortLink <> " resolve=all") + bob ##> ("/_connect plan 1 " <> shortLink <> " resolve=allGroups") bob <## "group link: known group #team" bob <## "use #team to send messages" -- no "SimpleX name" line: status stays unknown where @@ -201,7 +199,7 @@ testChannelDomainVerify ps = withSmpServerAndNames $ \reg -> bob ##> "/_verify domain #1" bob <## "SimpleX name #team not verified: the name does not resolve to the link in the group profile" -- a link-data refresh keeps the failed status, not overwritten with verified - bob ##> ("/_connect plan 1 " <> shortLink <> " resolve=all") + bob ##> ("/_connect plan 1 " <> shortLink <> " resolve=allGroups") bob <## "group link: known group #team" bob <## "SimpleX name: #team (verification failed)" bob <## "use #team to send messages" @@ -294,8 +292,8 @@ testConnectByNameChannelAndContact ps = withSmpServerAndNames $ \reg -> -- The bare name "acme.simplex" resolves to both a channel and a direct contact. The channel is tried -- first but its group profile does not claim the domain, so the channel side of the plan fails; the --- plan falls back to the direct contact as primary (planSimplexName) while otherSimplexName is the --- channel #acme, shown as "You can also join channel #acme". The channel link is a real, fetchable +-- plan falls back to the direct contact as primary (planSimplexName) without offering the channel; the +-- owner's plan offers channel #acme, shown as "You can also join channel #acme". The channel link is a real, fetchable -- #acme channel, so the failure is the faithful "channel does not claim this domain" case, not a broken link. testConnectByNameContactAndChannel :: HasCallStack => TestParams -> IO () testConnectByNameContactAndChannel ps = withSmpServerAndNames $ \reg -> @@ -382,12 +380,23 @@ withAliceName test ps = withSmpServerAndNames $ \reg -> where setup reg alice bob = do mapM_ enableNamesRole [alice, bob] - alice ##> "/ad" - (shortLink, _) <- getContactLinks alice True - registerName reg aliceSimplexName (contactNameRecord "alice.simplex" (T.pack shortLink)) - alice ##> "/_set domain 1 alice.simplex" - alice <## "new contact address set" - test reg (T.pack shortLink) alice bob + shortLink <- setAliceName reg alice + test reg shortLink alice bob + +setAliceName :: HasCallStack => NameRegistry -> TestCC -> IO Text +setAliceName reg alice = do + alice ##> "/ad" + (shortLink, _) <- getContactLinks alice True + registerName reg aliceSimplexName (contactNameRecord "alice.simplex" (T.pack shortLink)) + alice ##> "/_set domain 1 alice.simplex" + alice <## "new contact address set" + pure $ T.pack shortLink + +knownAlicePlan :: HasCallStack => TestCC -> IO () +knownAlicePlan bob = do + bob <## "contact address: known contact alice" + bob <## "SimpleX name: @alice.simplex (verified)" + bob <## "use @alice to send messages" connectBobByName :: HasCallStack => TestCC -> TestCC -> IO () connectBobByName alice bob = do @@ -402,22 +411,6 @@ connectBobByName alice bob = do (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") -testPlanNameExpired :: HasCallStack => TestParams -> IO () -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 <##. "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 <## "SimpleX name sunflower.simplex is available: $20 for 2 years" - where - sunflower = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "sunflower" []) - testPlanNameReservedCommunity :: HasCallStack => TestParams -> IO () testPlanNameReservedCommunity = withAliceName $ \reg _l _alice bob -> do registerReservedName reg privacy NRRCommunity @@ -449,18 +442,14 @@ testPlanKnownNameLive :: HasCallStack => TestParams -> IO () testPlanKnownNameLive = withAliceName $ \_reg _l alice bob -> do connectBobByName alice bob 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" + knownAlicePlan bob testPlanKnownNameExpired :: HasCallStack => TestParams -> IO () testPlanKnownNameExpired = withAliceName $ \reg shortLink alice bob -> do connectBobByName alice bob registerExpiredName reg aliceSimplexName (contactNameRecord "alice.simplex" shortLink) 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" + knownAlicePlan bob bob <##. "SimpleX name alice.simplex expired on " testPlanKnownNameAvailable :: HasCallStack => TestParams -> IO () @@ -468,9 +457,7 @@ testPlanKnownNameAvailable = withAliceName $ \reg _l alice bob -> do connectBobByName alice bob unregisterName reg aliceSimplexName 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" + knownAlicePlan bob bob <## "SimpleX name alice.simplex is no longer registered, available: $20 for 2 years" testPlanKnownNameReserved :: HasCallStack => TestParams -> IO () @@ -478,59 +465,49 @@ testPlanKnownNameReserved = withAliceName $ \reg _l alice bob -> do connectBobByName alice bob registerName reg aliceSimplexName (emptyRecord "alice.simplex") withCCTransaction bob $ \db -> DB.execute_ db "UPDATE contact_profiles SET contact_domain_resolved_at = datetime('now', '-2 days')" - planKnownAlice bob + bob ##> "/_connect plan 1 @alice.simplex resolve=all" + knownAlicePlan bob alice ##> "/_connect plan 1 @alice.simplex" alice <## "contact address: own address" registerExpiredName reg aliceSimplexName (emptyRecord "alice.simplex") bob ##> "/_connect plan 1 @alice.simplex" - bob <## "contact address: known contact alice" - bob <## "SimpleX name: @alice.simplex (verified)" - bob <## "use @alice to send messages" + knownAlicePlan bob registerReservedName reg aliceSimplexName NRRTrademark - planKnownAlice bob + bob ##> "/_connect plan 1 @alice.simplex resolve=all" + knownAlicePlan bob alice ##> "/_connect plan 1 @alice.simplex" alice <## "contact address: own address" registerReservedName reg aliceSimplexName NRRCommunity - planKnownAlice bob + bob ##> "/_connect plan 1 @alice.simplex resolve=all" + knownAlicePlan 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" + bob ##> "/_connect plan 1 @alice.simplex resolve=all" + knownAlicePlan bob 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 +testPlanKnownNameStale = withAliceName $ \reg shortLink alice bob -> do connectBobByName alice bob - planKnownAlice bob + registerExpiredName reg aliceSimplexName (contactNameRecord "alice.simplex" shortLink) + bob ##> "/_connect plan 1 @alice.simplex" + knownAlicePlan bob withCCTransaction bob $ \db -> DB.execute_ db "UPDATE contact_profiles SET contact_domain_resolved_at = datetime('now', '-2 days')" - planKnownAlice bob - planKnownAlice bob - withCCTransaction bob $ \db -> DB.execute_ db "UPDATE contact_profiles SET contact_domain_expires_at = datetime('now', '-1 hours')" - planKnownAlice bob - planKnownAlice bob - where - planKnownAlice bob = do - bob ##> "/_connect plan 1 @alice.simplex" - bob <## "contact address: known contact alice" - bob <## "SimpleX name: @alice.simplex (verified)" - bob <## "use @alice to send messages" + bob ##> "/_connect plan 1 @alice.simplex" + knownAlicePlan bob + bob <##. "SimpleX name alice.simplex expired on " + withCCTransaction bob $ \db -> DB.execute_ db "UPDATE contact_profiles SET contact_domain_resolved_at = datetime('now'), contact_domain_expires_at = datetime('now', '-1 hours')" + bob ##> "/_connect plan 1 @alice.simplex" + knownAlicePlan bob + bob <##. "SimpleX name alice.simplex expired on " testPlanNameResolvedEveryCall :: HasCallStack => TestParams -> IO () testPlanNameResolvedEveryCall = withAliceName $ \reg shortLink _alice bob -> do @@ -571,9 +548,7 @@ testPlanNameResolveNever :: HasCallStack => TestParams -> IO () testPlanNameResolveNever = withAliceName $ \_reg _l alice bob -> do connectBobByName alice bob bob ##> "/_connect plan 1 @alice.simplex resolve=never" - bob <## "contact address: known contact alice" - bob <## "SimpleX name: @alice.simplex (verified)" - bob <## "use @alice to send messages" + knownAlicePlan bob bob ##> "/_connect plan 1 @nobody.simplex resolve=never" bob <## "no matching chat found, name resolution is disabled" bob ##> "/_connect plan 1 nobody.simplex resolve=never" @@ -585,19 +560,13 @@ testPlanKnownNameAddressChanged ps = withSmpServerAndNames $ \reg -> where test reg alice bob cath = do mapM_ enableNamesRole [alice, bob, cath] - alice ##> "/ad" - (aliceLink, _) <- getContactLinks alice True - registerName reg aliceSimplexName (contactNameRecord "alice.simplex" (T.pack aliceLink)) - alice ##> "/_set domain 1 alice.simplex" - alice <## "new contact address set" + _ <- setAliceName reg alice connectBobByName alice bob 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" + knownAlicePlan bob alice ##> "/_connect plan 1 @alice.simplex" alice <## "contact address: own address" cath ##> "/_set domain 1 alice.simplex" @@ -606,16 +575,11 @@ testPlanKnownNameAddressChanged ps = withSmpServerAndNames $ \reg -> 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)" - bob <## "use @alice to send messages" + knownAlicePlan bob bob ##> "/_connect plan 1 @alice.simplex resolve=all" bob <## "contact address: ok to connect, address changed" - _ <- getTermLine bob -- the new address's short link data (JSON, printed in test view) - 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: ok to connect, address changed" _ <- getTermLine bob + 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: ok to connect, address changed" _ <- getTermLine bob @@ -623,9 +587,7 @@ testPlanKnownNameAddressChanged ps = withSmpServerAndNames $ \reg -> bob <## "contact address: ok to connect, address changed" _ <- getTermLine bob bob ##> "/_connect plan 1 @alice.simplex resolve=never" - bob <## "contact address: known contact alice" - bob <## "SimpleX name: @alice.simplex (verified)" - bob <## "use @alice to send messages" + knownAlicePlan bob testPlanNameResolverFailed :: HasCallStack => TestParams -> IO () testPlanNameResolverFailed = withAliceName $ \reg _l _alice bob -> do @@ -643,11 +605,7 @@ testPlanKnownNameLinkFailed ps = withSmpServerAndNames $ \reg -> where test reg alice bob cath = do mapM_ enableNamesRole [alice, bob, cath] - alice ##> "/ad" - (aliceLink, _) <- getContactLinks alice True - registerName reg aliceSimplexName (contactNameRecord "alice.simplex" (T.pack aliceLink)) - alice ##> "/_set domain 1 alice.simplex" - alice <## "new contact address set" + _ <- setAliceName reg alice connectBobByName alice bob cath ##> "/ad" (cathLink, _) <- getContactLinks cath True @@ -656,17 +614,7 @@ testPlanKnownNameLinkFailed ps = withSmpServerAndNames $ \reg -> cath <## "To create a new chat address use /ad" 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" - -testPlanNameLiveCommunity :: HasCallStack => TestParams -> IO () -testPlanNameLiveCommunity = withAliceName $ \reg shortLink _alice bob -> do - registerReservedLiveName reg aliceSimplexName NRRCommunity (contactNameRecord "alice.simplex" shortLink) - bob ##> "/_connect plan 1 @alice.simplex" - bob <## "contact address: ok to connect" - _ <- getTermLine bob - pure () + knownAlicePlan bob testPlanChannelNameMoved :: HasCallStack => TestParams -> IO () testPlanChannelNameMoved ps = withSmpServerAndNames $ \reg -> @@ -712,7 +660,7 @@ testPlanChannelNameMoved ps = withSmpServerAndNames $ \reg -> where teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" []) -nameWarningTests :: Spec +nameWarningTests :: SpecWith TestParams nameWarningTests = do it "warning for a name with no local chat" $ \_ -> testNameLinkOrWarning it "warning for own name" $ \_ -> testOwnNameWarning diff --git a/tests/NameResolver.hs b/tests/NameResolver.hs index 36407fc982..203c1ca3b1 100644 --- a/tests/NameResolver.hs +++ b/tests/NameResolver.hs @@ -1,10 +1,9 @@ -{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} -- | Local HTTP names resolver for chat tests, copied from simplexmq's -- NamesResolverServer and made dynamic: it answers /v2/resolve/ from a --- mutable name -> NameRecord registry, so a test can resolve a name to the +-- mutable name -> answer registry, so a test can resolve a name to the -- address it just created. module NameResolver ( NameRegistry, @@ -12,8 +11,6 @@ module NameResolver registerName, registerExpiredName, registerReservedName, - registerReservedLiveName, - registerAvailableName, unregisterName, failNameResolution, emptyRecord, @@ -57,10 +54,9 @@ withNameResolver action = do ["v2", "resolve", q] -> answer . M.lookup q <$> readTVarIO reg _ -> pure (notFound404, "{}") send $ responseLBS st [(hContentType, "application/json")] body - answer = \case - Just AnswerFails -> (internalServerError500, "{}") - Just (AnswerRegistration registration) -> (ok200, J.encode NameResponse {lastBlockTs = Nothing, registration}) - Nothing -> (ok200, J.encode NameResponse {lastBlockTs = Nothing, registration = NRAvailable {pricing = testPricing 1}}) + answer (Just AnswerFails) = (internalServerError500, "{}") + answer (Just (AnswerRegistration registration)) = (ok200, J.encode NameResponse {lastBlockTs = Nothing, registration}) + answer Nothing = (ok200, J.encode NameResponse {lastBlockTs = Nothing, registration = NRAvailable {pricing = NamePricing {registrationPrices = M.empty, basePrice = USDCents 1000, minLabelLength = 1}}}) -- | Register a name's domain to resolve to the given record. registerName :: NameRegistry -> SimplexNameInfo -> NameRecord -> IO () @@ -80,13 +76,6 @@ registerExpiredName reg ni nameRecord = do registerReservedName :: NameRegistry -> SimplexNameInfo -> NameReservedReason -> IO () registerReservedName reg ni reservedReason = registerRegistration reg ni NRReserved {reservedReason} --- | A live registration also held back by the registry, which is why it will not free up at expiry. -registerReservedLiveName :: NameRegistry -> SimplexNameInfo -> NameReservedReason -> NameRecord -> IO () -registerReservedLiveName reg ni reason nameRecord = - registerRegistration reg ni NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Just reason, nameRecord} - -registerAvailableName :: NameRegistry -> SimplexNameInfo -> Int -> IO () -registerAvailableName reg ni minLen = registerRegistration reg ni NRAvailable {pricing = testPricing minLen} failNameResolution :: NameRegistry -> SimplexNameInfo -> IO () failNameResolution reg ni = atomically $ modifyTVar' reg $ M.insert (registryKey ni) AnswerFails @@ -98,9 +87,6 @@ registryKey :: SimplexNameInfo -> Text registryKey SimplexNameInfo {nameDomain = SimplexDomain {nameTLD, domain}} = decodeLatin1 $ strEncode (labelHash domain) <> strEncode nameTLD -testPricing :: Int -> NamePricing -testPricing minLabelLength = NamePricing {registrationPrices = M.empty, basePrice = USDCents 1000, minLabelLength} - contactNameRecord :: Text -> Text -> NameRecord contactNameRecord name link = (emptyRecord name) {nrSimplexContact = [link]} diff --git a/tests/Test.hs b/tests/Test.hs index ff64b7b40f..dd2a7b8d59 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, nameWarningTests) +import ChatTests.Names (chatNamesTests) import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System @@ -83,7 +83,6 @@ 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