From 86556408bad9fa12546b996930529bbd539abce7 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 30 Sep 2026 10:55:55 +0200 Subject: [PATCH] align app divergences --- .../Shared/Views/NewChat/NewChatView.swift | 36 ++++++++----------- .../common/views/newchat/ConnectPlan.kt | 8 ++--- plans/2026-09-22-name-lookup-core-api.md | 2 +- plans/2026-09-28-name-warnings.md | 2 +- src/Simplex/Chat/Library/Commands.hs | 11 +++--- tests/ChatTests/Names.hs | 20 +++++++++++ 6 files changed, 47 insertions(+), 32 deletions(-) diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 712977b90a..22a65bdf9a 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1426,14 +1426,6 @@ private func showNameWarningAlert( } } -@MainActor private func nameChatId(_ chatInfo: ChatInfo?) -> ChatId? { - guard let chatInfo else { return nil } - if ChatModel.shared.getChat(chatInfo.id) == nil { - ChatModel.shared.addChat(Chat(chatInfo: chatInfo, chatItems: [])) - } - return chatInfo.id -} - private func showOtherNameAlert(_ otherSimplexName: SimplexNameInfo, connectOtherButton: String, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)?) { showAlert( String.localizedStringWithFormat( @@ -1610,7 +1602,6 @@ func planAndConnect( case let .ok(contactSLinkData_, ownerVerification, addressChanged, existingChat_): if let contactSLinkData = contactSLinkData_ { logger.debug("planAndConnect, .contactAddress, .ok, short link data present") - let existingChatId = await nameChatId(filterKnownContact == nil ? existingChat_ : nil) await MainActor.run { showPrepareContactAlert( connectionLink: connectionLink, @@ -1620,7 +1611,7 @@ func planAndConnect( connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, addressChanged: addressChanged, - openExistingChat: existingChatId.map { chatId in { openKnownChat(chatId, dismiss: dismiss, cleanup: cleanup) } }, + openExistingChat: (filterKnownContact == nil ? existingChat_ : nil).map { chatInfo in { openKnownChat(chatInfo.id, dismiss: dismiss, cleanup: cleanup) } }, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1704,7 +1695,6 @@ func planAndConnect( case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification, addressChanged, existingChat_): if let groupSLinkData = groupSLinkData_ { logger.debug("planAndConnect, .groupLink, .ok, short link data present") - let existingChatId = await nameChatId(filterKnownGroup == nil ? existingChat_ : nil) await MainActor.run { showPrepareGroupAlert( connectionLink: connectionLink, @@ -1715,7 +1705,7 @@ func planAndConnect( connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, addressChanged: addressChanged, - openExistingChat: existingChatId.map { chatId in { openKnownChat(chatId, dismiss: dismiss, cleanup: cleanup) } }, + openExistingChat: (filterKnownGroup == nil ? existingChat_ : nil).map { chatInfo in { openKnownChat(chatInfo.id, dismiss: dismiss, cleanup: cleanup) } }, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1739,16 +1729,20 @@ func planAndConnect( await MainActor.run { if let f = filterKnownGroup { f(groupInfo) + if let otherSimplexName = result.otherSimplexName, let connectOtherButton { + showOtherNameAlert(otherSimplexName, connectOtherButton: connectOtherButton, theme: theme, dismiss: dismiss, cleanup: cleanup) + } + } else { + showOwnGroupLinkConfirmConnectSheet( + groupInfo: groupInfo, + connectionLink: connectionLink, + connectionPlan: connectionPlan, + connectOtherButton: connectOtherButton, + onConnectOther: connectOtherLink.map { link in { planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup) } }, + dismiss: dismiss, + cleanup: cleanup + ) } - showOwnGroupLinkConfirmConnectSheet( - groupInfo: groupInfo, - connectionLink: connectionLink, - connectionPlan: connectionPlan, - connectOtherButton: connectOtherButton, - onConnectOther: connectOtherLink.map { link in { planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup) } }, - dismiss: dismiss, - cleanup: cleanup - ) } case .connectingConfirmReconnect: logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect") 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 c667d9c2f6..d3c26baba4 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 @@ -290,7 +290,7 @@ private suspend fun planAndConnectTask( connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, addressChanged = connectionPlan.contactAddressPlan.addressChanged, - openExistingChat = if (filterKnownContact == null) localNameChat(rhId, connectionPlan.contactAddressPlan.existingChat_)?.let { chat -> { openChat_(chatModel, rhId, close, chat); cleanup() } } else null, + openExistingChat = if (filterKnownContact == null) connectionPlan.contactAddressPlan.existingChat_?.let { chatInfo -> { openChat_(chatModel, rhId, close, Chat(remoteHostId = rhId, chatInfo = chatInfo, chatItems = emptyList())); cleanup() } } else null, close, cleanup ) @@ -388,7 +388,7 @@ private suspend fun planAndConnectTask( connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, addressChanged = connectionPlan.groupLinkPlan.addressChanged, - openExistingChat = if (filterKnownGroup == null) localNameChat(rhId, connectionPlan.groupLinkPlan.existingChat_)?.let { chat -> { openChat_(chatModel, rhId, close, chat); cleanup() } } else null, + openExistingChat = if (filterKnownGroup == null) connectionPlan.groupLinkPlan.existingChat_?.let { chatInfo -> { openChat_(chatModel, rhId, close, Chat(remoteHostId = rhId, chatInfo = chatInfo, chatItems = emptyList())); cleanup() } } else null, close, cleanup ) @@ -410,6 +410,7 @@ private suspend fun planAndConnectTask( val groupInfo = connectionPlan.groupLinkPlan.groupInfo if (filterKnownGroup != null) { filterKnownGroup(groupInfo) + if (otherSimplexName != null && connectOtherButton != null) showOtherNameAlert(rhId, otherSimplexName, connectOtherButton, close, cleanup) } else { ownGroupLinkConfirmConnect(chatModel, rhId, connectionLink, linkText, connectionPlan, groupInfo, close, cleanup, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink) } @@ -888,9 +889,6 @@ fun showPrepareContactAlert( ) } -private suspend fun localNameChat(rhId: Long?, chatInfo: ChatInfo?): Chat? = - chatInfo?.let { chatModel.getChat(it.id) ?: Chat(remoteHostId = rhId, chatInfo = it, chatItems = emptyList()).also { chat -> chatModel.chatsContext.addChat(chat) } } - private fun showOtherNameAlert(rhId: Long?, otherSimplexName: SimplexNameInfo, connectOtherButton: String, close: (() -> Unit)?, cleanup: (() -> Unit)?) { AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = String.format( diff --git a/plans/2026-09-22-name-lookup-core-api.md b/plans/2026-09-22-name-lookup-core-api.md index ba4a86d2e9..7e019fb244 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. 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. Setting the flag to `True` clears it on the user's other chats of the name's kind (contacts and business chats for a contact name, channels for a channel name), so after "Open new chat" in 3c the name finds the new chat. +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, only when the link's profile claims the name the app passes. 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. Setting the flag to `True` clears it on the user's other chats of the name's kind (contacts and business chats for a contact name, channels for a channel name), so after "Open new chat" in 3c the name finds the new chat. 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 index 58db4fa5aa..cddeb83abd 100644 --- a/plans/2026-09-28-name-warnings.md +++ b/plans/2026-09-28-name-warnings.md @@ -242,7 +242,7 @@ A missing `graceUntil` drops the second clause of the expiry lines. `otherSimple - **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. +- **3e (new):** a bare name matches a chat or own channel, 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. diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index cc4ddd5460..3451091c08 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2181,6 +2181,8 @@ processChatCommand cxt nm = \case APIConnectPlan _ Nothing _ _ -> throwChatError CEInvalidConnReq APIPrepareContact userId accLink verifiedDomain contactSLinkData -> withUserId userId $ \user -> do let ContactShortLinkData {profile, message, business} = contactSLinkData + Profile {contactDomain} = profile + domain_ = if verifiedDomain == (claimDomain <$> contactDomain) then verifiedDomain else Nothing welcomeSharedMsgId <- forM message $ \_ -> getSharedMsgId case accLink of ACCL SCMContact ccLink @@ -2189,7 +2191,7 @@ processChatCommand cxt nm = \case groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences groupProfile = businessGroupProfile profile groupPreferences gVar <- asks random - (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing verifiedDomain + (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing domain_ hostMember <- maybe (throwCmdError "no host member") pure hostMember_ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let cd = CDGroupRcv gInfo Nothing hostMember @@ -2202,7 +2204,7 @@ processChatCommand cxt nm = \case _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTGroup chat ACCL _ (CCLink cReq _) -> do - ct <- withStore $ \db -> createPreparedContact db cxt user profile accLink welcomeSharedMsgId (True <$ verifiedDomain) + ct <- withStore $ \db -> createPreparedContact db cxt user profile accLink welcomeSharedMsgId (True <$ domain_) void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart) let cd = CDDirectRcv ct createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing Nothing @@ -2215,9 +2217,10 @@ processChatCommand cxt nm = \case _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTDirect chat APIPrepareGroup userId ccLink direct verifiedDomain groupSLinkData -> withUserId userId $ \user -> do - let GroupShortLinkData {groupProfile = GroupProfile {description}} = groupSLinkData + let GroupShortLinkData {groupProfile = GroupProfile {description, publicGroup}} = groupSLinkData + domain_ = if verifiedDomain == (claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim)) then verifiedDomain else Nothing welcomeSharedMsgId <- forM description $ \_ -> getSharedMsgId - (gInfo, hostMember_) <- preparedGroupFromLink user ccLink direct groupSLinkData welcomeSharedMsgId verifiedDomain + (gInfo, hostMember_) <- preparedGroupFromLink user ccLink direct groupSLinkData welcomeSharedMsgId domain_ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_ cInfo = GroupChat gInfo Nothing diff --git a/tests/ChatTests/Names.hs b/tests/ChatTests/Names.hs index 11a087783d..75a6289dd9 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -34,6 +34,7 @@ chatNamesTests :: SpecWith TestParams chatNamesTests = do it "connect by resolved name" testConnectByName it "connect by name not claimed in link profile is rejected" testConnectByNameNotClaimed + it "prepare with a name not claimed in link profile is not verified" testPrepareNameNotClaimed it "connect by name to a known contact not claimed in profile is rejected" testConnectByNameKnownContactNotClaimed it "connect by unregistered name reports it is available" testConnectByNameNotFound it "set name not resolving to own address is rejected" testSetNameNotOwnAddress @@ -447,6 +448,25 @@ setContactNamesStale cc = withCCTransaction cc $ \db -> DB.execute_ db "UPDATE c setGroupNamesStale :: TestCC -> IO () setGroupNamesStale cc = withCCTransaction cc $ \db -> DB.execute_ db "UPDATE groups SET group_domain_resolved_at = datetime('now', '-2 days')" +testPrepareNameNotClaimed :: HasCallStack => TestParams -> IO () +testPrepareNameNotClaimed ps = withSmpServerAndNames $ \reg -> + testChat2 aliceProfile bobProfile (test reg) ps + where + test reg alice bob = do + mapM_ enableNamesRole [alice, bob] + alice ##> "/ad" + (shortLink, fullLink) <- getContactLinks alice True + registerName reg aliceSimplexName (contactNameRecord "alice.simplex" (T.pack shortLink)) + alice ##> "/_set domain 1 alice.simplex" + alice <## "new contact address set" + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "contact address: ok to connect" + contactSLinkData <- getTermLine bob + bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " domain=bob.simplex " <> contactSLinkData) + bob <## "alice: contact is prepared" + bob ##> "/_connect plan 1 @alice.simplex resolve=never" + bob <## "no matching chat found, name resolution is disabled" + testPlanNameReservedOther :: HasCallStack => TestParams -> IO () testPlanNameReservedOther = withAliceName $ \reg _r _alice bob -> do registerReservedName reg acmeName NRRTrademark