diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift index d231f0d8aa..253dca67c5 100644 --- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift +++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift @@ -208,8 +208,14 @@ struct ContactListNavLink: View { .tint(.red) } .confirmationDialog("Connect with \(contact.chatViewName)", isPresented: $showConnectContactViaAddressDialog, titleVisibility: .visible) { - Button("Use current profile") { connectContactViaAddress_(contact, false) } - Button("Use new incognito profile") { connectContactViaAddress_(contact, true) } + if !contact.profileChangeProhibited { + Button("Use current profile") { connectContactViaAddress_(contact, false) } + Button("Use new incognito profile") { connectContactViaAddress_(contact, true) } + } else if !contact.contactConnIncognito { + Button("Use current profile") { connectContactViaAddress_(contact, false) } + } else { + Button("Use incognito profile") { connectContactViaAddress_(contact, true) } + } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 359e1621cf..35b6ee99c6 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -183,8 +183,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.7-HtMnGcLT3joL6B8buibOdF-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.7-HtMnGcLT3joL6B8buibOdF-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.7-HtMnGcLT3joL6B8buibOdF.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.7-HtMnGcLT3joL6B8buibOdF.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.1.0-CLkstteZ5zsL7AgM2nJh38-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.1.0-CLkstteZ5zsL7AgM2nJh38-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.1.0-CLkstteZ5zsL7AgM2nJh38.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.1.0-CLkstteZ5zsL7AgM2nJh38.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -553,8 +553,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.7-HtMnGcLT3joL6B8buibOdF-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.7-HtMnGcLT3joL6B8buibOdF-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.7-HtMnGcLT3joL6B8buibOdF.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.7-HtMnGcLT3joL6B8buibOdF.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.1.0-CLkstteZ5zsL7AgM2nJh38-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.1.0-CLkstteZ5zsL7AgM2nJh38-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.1.0-CLkstteZ5zsL7AgM2nJh38.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.1.0-CLkstteZ5zsL7AgM2nJh38.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -714,8 +714,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.7-HtMnGcLT3joL6B8buibOdF-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.7-HtMnGcLT3joL6B8buibOdF.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.1.0-CLkstteZ5zsL7AgM2nJh38-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.1.0-CLkstteZ5zsL7AgM2nJh38.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -800,8 +800,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.7-HtMnGcLT3joL6B8buibOdF-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.7-HtMnGcLT3joL6B8buibOdF.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.1.0-CLkstteZ5zsL7AgM2nJh38-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.1.0-CLkstteZ5zsL7AgM2nJh38.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 4e73795546..9b7f4ac5ee 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1793,7 +1793,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { } public var isContactCard: Bool { - activeConn == nil && profile.contactLink != nil && active && preparedContact == nil && contactRequestId == nil + (activeConn == nil || activeConn?.connStatus == .prepared) && profile.contactLink != nil && active && preparedContact == nil && contactRequestId == nil } public var contactConnIncognito: Bool { 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 779e6ec88b..a7ede11160 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 @@ -1778,7 +1778,7 @@ data class Contact( } val isContactCard: Boolean = - activeConn == null && profile.contactLink != null && active && preparedContact == null && contactRequestId == null + (activeConn == null || activeConn.connStatus == ConnStatus.Prepared) && profile.contactLink != null && active && preparedContact == null && contactRequestId == null val contactConnIncognito = activeConn?.customUserProfileId != null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 4f06277e6f..aa74596361 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -794,33 +794,49 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( close: (() -> Unit)?, openChat: Boolean ) { + @Composable + fun UseCurrentProfileButton() { + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + withBGApi { + val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = false) + if (ok && openChat) { + close?.invoke() + openDirectChat(rhId, contact.contactId) + } + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + + @Composable + fun UseIncognitoProfileButton(text: String) { + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + withBGApi { + val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = true) + if (ok && openChat) { + close?.invoke() + openDirectChat(rhId, contact.contactId) + } + } + }) { + Text(text, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = String.format(generalGetString(MR.strings.connect_with_contact_name_question), contact.chatViewName), buttons = { Column { - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - withBGApi { - close?.invoke() - val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = false) - if (ok && openChat) { - openDirectChat(rhId, contact.contactId) - } - } - }) { - Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - withBGApi { - close?.invoke() - val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = true) - if (ok && openChat) { - openDirectChat(rhId, contact.contactId) - } - } - }) { - Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + if (!contact.profileChangeProhibited) { + UseCurrentProfileButton() + UseIncognitoProfileButton(generalGetString(MR.strings.connect_use_new_incognito_profile)) + } else if (!contact.contactConnIncognito) { + UseCurrentProfileButton() + } else { + UseIncognitoProfileButton(generalGetString(MR.strings.connect_use_incognito_profile)) } SectionItemView({ AlertManager.privacySensitive.hideAlert() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt index 90e23e1cb5..b86f6d7a3e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt @@ -104,6 +104,9 @@ fun ContactPreviewView( modifier = Modifier .size(21.dp) ) + if (chat.chatInfo.incognito) { + Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) + } } if (showDeletedChatIcon && chat.chatInfo.chatDeleted) { 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 af51bd5ba6..98c2b7235e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -9,6 +9,7 @@ Join group? Use current profile Use new incognito profile + Use incognito profile Your profile will be sent to the contact that you received this link from. You will connect to all group members. Connect diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index cf85ef6eac..4b61c4afa1 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -541,11 +541,6 @@ RcvGroupE2EEInfo: ChatBanner: - type: "chatBanner" -InvalidJSON: -- type: "invalidJSON" -- direction: [MsgDirection](#msgdirection) -- json: string - --- diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 188515311d..8309ee28ba 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -179,8 +179,8 @@ ciQuoteType = chatTypesDocsData :: [(SumTypeInfo, SumTypeJsonEncoding, String, [ConsName], Expr, Text)] chatTypesDocsData = [ ((sti @(Chat 'CTDirect)) {typeName = "AChat"}, STRecord, "", [], "", ""), - ((sti @JSONChatInfo) {typeName = "ChatInfo"}, STUnion, "JCInfo", [], "", ""), - ((sti @JSONCIContent) {typeName = "CIContent"}, STUnion, "JCI", [], "", ""), + ((sti @JSONChatInfo) {typeName = "ChatInfo"}, STUnion, "JCInfo", ["JCInfoInvalidJSON"], "", ""), + ((sti @JSONCIContent) {typeName = "CIContent"}, STUnion, "JCI", ["JCIInvalidJSON"], "", ""), ((sti @JSONCIDeleted) {typeName = "CIDeleted"}, STUnion, "JCID", [], "", ""), ((sti @JSONCIDirection) {typeName = "CIDirection"}, STUnion, "JCI", [], "", ""), ((sti @JSONCIFileStatus) {typeName = "CIFileStatus"}, STUnion, "JCIFS", [], "", ""), diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index e7ce63ea54..f163335388 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,9 +1,9 @@ --- title: Contributing guide -revision: 31.01.2023 +revision: 25.07.2025 --- -| Updated 31.01.2023 | Languages: EN, [FR](/docs/lang/fr/CONTRIBUTING.md), [CZ](/docs/lang/cs/CONTRIBUTING.md), [PL](/docs/lang/pl/CONTRIBUTING.md) | +| Updated 25.07.2025 | Languages: EN, [FR](/docs/lang/fr/CONTRIBUTING.md), [CZ](/docs/lang/cs/CONTRIBUTING.md), [PL](/docs/lang/pl/CONTRIBUTING.md) | # Contributing guide @@ -113,3 +113,23 @@ import Control.Monad ``` [This PR](https://github.com/simplex-chat/simplex-chat/pull/2975/files) has all the differences. + + +## Improving compatibility between versions for remote desktop connection + +UI already can handle failed JSON conversions of chats and chat items, and it helps both debugging and downgrading. + +While we can increase versions for remote connections to make different versions incompatible, it degrades remote connection UX, as in many cases users can't upgrade mobile or desktop apps at the same time because of different release cycles. + +It is especially problematic for Android app users, as they can only downgrade via Export/Import - older version can't be installed on top of newer version. + +PR #6105 improved it by: +- adding CInfoInvalidJSON constructor, so that chats that cannot be parsed will show as "invalid chat" via remote connection (as when UI has field not present in API), +- changing JSON parsing for CIContent, so that it falls back to CIInvalidJSON in platform-specific JSON parser. + +To avoid "invalid" chats in the list we need to maintain forward compatibility on JSON encoding level of AChat type and subtypes: +- add new fields as optional to these types, +- add `omittedField` method to FromJSON instances of types of new fields to provide a default value, where appropriate, +- define primitive non-optional fields as newtype with `omittedField` in JSON instance. + +To avoid fallback to invalid JSON in chat items we should do the same for ChatItem type and subtypes. It's especially important when adding fields to types used for all CIContent, as otherwise all items will be broken. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 13a1f3afe1..59e8008fce 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.4.1.0 +version: 6.4.1.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 0066bc1ee9..170684959f 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1909,14 +1909,18 @@ processChatCommand vr nm = \case Connect _ Nothing -> throwChatError CEInvalidConnReq APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do ct@Contact {activeConn, profile = LocalProfile {contactLink}} <- withFastStore $ \db -> getContact db vr user contactId - when (isJust activeConn) $ throwCmdError "contact already has connection" ccLink <- case contactLink of Just (CLFull cReq) -> pure $ CCLink cReq Nothing Just (CLShort sLnk) -> do (cReq, _cData) <- getShortLinkConnReq user sLnk pure $ CCLink cReq $ Just sLnk Nothing -> throwCmdError "no address in contact profile" - connectContactViaAddress user incognito ct ccLink + connectContactViaAddress user incognito ct ccLink `catchChatError` \e -> do + -- get updated contact, in case connection was started - in UI it would lock ability to change incognito choice + -- on next connection attempt, in case server received request while client got network error + ct' <- withFastStore $ \db -> getContact db vr user contactId + toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') + throwError e ConnectSimplex incognito -> withUser $ \user -> do plan <- contactRequestPlan user adminContactReq Nothing `catchChatError` const (pure $ CPContactAddress (CAPOk Nothing)) connectWithPlan user incognito (ACCL SCMContact (CCLink adminContactReq Nothing)) plan @@ -3021,7 +3025,6 @@ processChatCommand vr nm = \case let incognitoProfile = fromLocalProfile <$> localIncognitoProfile conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ inGroup PQSupportOn pure $ CVRSentInvitation conn' incognitoProfile - mkXContactId = maybe (XContactId <$> drgRandomBytes 16) pure connect' groupLinkId cReqHash xContactId_ = do let inGroup = isJust groupLinkId pqSup = if inGroup then PQSupportOff else PQSupportOn @@ -3035,19 +3038,31 @@ processChatCommand vr nm = \case conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ inGroup pqSup pure $ CVRSentInvitation conn' incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse - connectContactViaAddress user@User {userId} incognito ct@Contact {contactId} (CCLink cReq shortLink) = - withInvitationLock "connectContactViaAddress" (strEncode cReq) $ do - newXContactId <- XContactId <$> drgRandomBytes 16 - let pqSup = PQSupportOn - (connId, chatV) <- prepareContact user cReq pqSup - let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - -- [incognito] generate profile to send - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - subMode <- chatReadVar subscriptionMode - conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReqHash shortLink newXContactId incognitoProfile Nothing subMode chatV pqSup - void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing False pqSup - ct' <- withStore $ \db -> getContact db vr user contactId - pure $ CRSentInvitationToContact user ct' incognitoProfile + connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) = + withInvitationLock "connectContactViaAddress" (strEncode cReq) $ + case activeConn of + Nothing -> do + let pqSup = PQSupportOn + (connId, chatV) <- prepareContact user cReq pqSup + newXContactId <- XContactId <$> drgRandomBytes 16 + -- [incognito] generate profile to send + incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + subMode <- chatReadVar subscriptionMode + let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq + conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReqHash shortLink newXContactId incognitoProfile Nothing subMode chatV pqSup + void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing False pqSup + ct' <- withStore $ \db -> getContact db vr user contactId + pure $ CRSentInvitationToContact user ct' incognitoProfile + Just conn@Connection {connStatus, xContactId = xContactId_, customUserProfileId} -> case connStatus of + ConnPrepared -> do + when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" + xContactId <- mkXContactId xContactId_ + localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId + let incognitoProfile = fromLocalProfile <$> localIncognitoProfile + void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing False PQSupportOn + ct' <- withStore $ \db -> getContact db vr user contactId + pure $ CRSentInvitationToContact user ct' incognitoProfile + _ -> throwCmdError "contact already has connection" prepareContact :: User -> ConnReqContact -> PQSupport -> CM (ConnId, VersionChat) prepareContact user cReq pqSup = do -- 0) toggle disabled - PQSupportOff @@ -3059,6 +3074,8 @@ processChatCommand vr nm = \case let chatV = agentToChatVersion agentV connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup pure (connId, chatV) + mkXContactId :: Maybe XContactId -> CM XContactId + mkXContactId = maybe (XContactId <$> drgRandomBytes 16) pure joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Bool -> PQSupport -> CM Connection joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ inGroup pqSup = do let profileToSend = diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 5c90554e53..5166ce7ecb 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -546,6 +546,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (gInfo, host) <- withStore $ \db -> do liftIO $ deleteContactCardKeepConn db connId ct createGroupInvitedViaLink db vr user conn'' glInv + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) let profileToSend = userProfileInGroup user (fromLocalProfile <$> incognitoProfile) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index c418a50738..5a5f0922e7 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -24,6 +24,7 @@ import Data.Aeson (FromJSON, ToJSON, (.:)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ +import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Lazy.Char8 as LB @@ -61,6 +62,61 @@ import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection deriving (Eq, Show, Ord) +$(JQ.deriveJSON (enumJSON $ dropPrefix "CT") ''ChatType) + +data SChatType (c :: ChatType) where + SCTDirect :: SChatType 'CTDirect + SCTGroup :: SChatType 'CTGroup + SCTLocal :: SChatType 'CTLocal + SCTContactRequest :: SChatType 'CTContactRequest + SCTContactConnection :: SChatType 'CTContactConnection + +deriving instance Show (SChatType c) + +instance TestEquality SChatType where + testEquality SCTDirect SCTDirect = Just Refl + testEquality SCTGroup SCTGroup = Just Refl + testEquality SCTLocal SCTLocal = Just Refl + testEquality SCTContactRequest SCTContactRequest = Just Refl + testEquality SCTContactConnection SCTContactConnection = Just Refl + testEquality _ _ = Nothing + +data AChatType = forall c. ChatTypeI c => ACT (SChatType c) + +class ChatTypeI (c :: ChatType) where + chatTypeI :: SChatType c + +instance ChatTypeI 'CTDirect where chatTypeI = SCTDirect + +instance ChatTypeI 'CTGroup where chatTypeI = SCTGroup + +instance ChatTypeI 'CTLocal where chatTypeI = SCTLocal + +instance ChatTypeI 'CTContactRequest where chatTypeI = SCTContactRequest + +instance ChatTypeI 'CTContactConnection where chatTypeI = SCTContactConnection + +toChatType :: SChatType c -> ChatType +toChatType = \case + SCTDirect -> CTDirect + SCTGroup -> CTGroup + SCTLocal -> CTLocal + SCTContactRequest -> CTContactRequest + SCTContactConnection -> CTContactConnection + +aChatType :: ChatType -> AChatType +aChatType = \case + CTDirect -> ACT SCTDirect + CTGroup -> ACT SCTGroup + CTLocal -> ACT SCTLocal + CTContactRequest -> ACT SCTContactRequest + CTContactConnection -> ACT SCTContactConnection + +checkChatType :: forall t c c'. (ChatTypeI c, ChatTypeI c') => t c' -> Either String (t c) +checkChatType x = case testEquality (chatTypeI @c) (chatTypeI @c') of + Just Refl -> Right x + Nothing -> Left "bad chat type" + data GroupChatScope = GCSMemberSupport {groupMemberId_ :: Maybe GroupMemberId} -- Nothing means own conversation with support deriving (Eq, Show, Ord) @@ -113,6 +169,7 @@ data ChatInfo (c :: ChatType) where LocalChat :: NoteFolder -> ChatInfo 'CTLocal ContactRequest :: UserContactRequest -> ChatInfo 'CTContactRequest ContactConnection :: PendingContactConnection -> ChatInfo 'CTContactConnection + CInfoInvalidJSON :: SChatType c -> J.Object -> ChatInfo c -- this constructor is needed to catch JSON errors for Remote connection parsing deriving instance Show (ChatInfo c) @@ -146,13 +203,14 @@ memberEventForwardScope m@GroupMember {memberRole, memberStatus} | memberRole >= GRModerator = Just GFSAll | otherwise = Just GFSMain -chatInfoToRef :: ChatInfo c -> ChatRef +chatInfoToRef :: ChatInfo c -> Maybe ChatRef chatInfoToRef = \case - DirectChat Contact {contactId} -> ChatRef CTDirect contactId Nothing - GroupChat GroupInfo {groupId} scopeInfo -> ChatRef CTGroup groupId (toChatScope <$> scopeInfo) - LocalChat NoteFolder {noteFolderId} -> ChatRef CTLocal noteFolderId Nothing - ContactRequest UserContactRequest {contactRequestId} -> ChatRef CTContactRequest contactRequestId Nothing - ContactConnection PendingContactConnection {pccConnId} -> ChatRef CTContactConnection pccConnId Nothing + DirectChat Contact {contactId} -> Just $ ChatRef CTDirect contactId Nothing + GroupChat GroupInfo {groupId} scopeInfo -> Just $ ChatRef CTGroup groupId (toChatScope <$> scopeInfo) + LocalChat NoteFolder {noteFolderId} -> Just $ ChatRef CTLocal noteFolderId Nothing + ContactRequest UserContactRequest {contactRequestId} -> Just $ ChatRef CTContactRequest contactRequestId Nothing + ContactConnection PendingContactConnection {pccConnId} -> Just $ ChatRef CTContactConnection pccConnId Nothing + CInfoInvalidJSON {} -> Nothing chatInfoMembership :: ChatInfo c -> Maybe GroupMember chatInfoMembership = \case @@ -165,10 +223,17 @@ data JSONChatInfo | JCInfoLocal {noteFolder :: NoteFolder} | JCInfoContactRequest {contactRequest :: UserContactRequest} | JCInfoContactConnection {contactConnection :: PendingContactConnection} + | JCInfoInvalidJSON {chatType :: ChatType, json :: J.Object} $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GCSI") ''GroupChatScopeInfo) -$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCInfo") ''JSONChatInfo) +$(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "JCInfo") ''JSONChatInfo) + +instance FromJSON JSONChatInfo where + parseJSON v@(J.Object o) = + $(JQ.mkParseJSON (sumTypeJSON $ dropPrefix "JCInfo") ''JSONChatInfo) v + <|> ((`JCInfoInvalidJSON` o) <$> o .: "type") -- fallback for forward compatible remote parser + parseJSON invalid = JT.typeMismatch "Object" invalid instance ChatTypeI c => FromJSON (ChatInfo c) where parseJSON v = (\(AChatInfo _ c) -> checkChatType c) <$?> J.parseJSON v @@ -184,6 +249,7 @@ jsonChatInfo = \case LocalChat l -> JCInfoLocal l ContactRequest g -> JCInfoContactRequest g ContactConnection c -> JCInfoContactConnection c + CInfoInvalidJSON c o -> JCInfoInvalidJSON (toChatType c) o data AChatInfo = forall c. ChatTypeI c => AChatInfo (SChatType c) (ChatInfo c) @@ -196,6 +262,7 @@ jsonAChatInfo = \case JCInfoLocal l -> AChatInfo SCTLocal $ LocalChat l JCInfoContactRequest g -> AChatInfo SCTContactRequest $ ContactRequest g JCInfoContactConnection c -> AChatInfo SCTContactConnection $ ContactConnection c + JCInfoInvalidJSON cType o -> case aChatType cType of ACT c -> AChatInfo c $ CInfoInvalidJSON c o instance FromJSON AChatInfo where parseJSON v = jsonAChatInfo <$> J.parseJSON v @@ -1087,59 +1154,6 @@ type ChatItemId = Int64 type ChatItemTs = UTCTime -data SChatType (c :: ChatType) where - SCTDirect :: SChatType 'CTDirect - SCTGroup :: SChatType 'CTGroup - SCTLocal :: SChatType 'CTLocal - SCTContactRequest :: SChatType 'CTContactRequest - SCTContactConnection :: SChatType 'CTContactConnection - -deriving instance Show (SChatType c) - -instance TestEquality SChatType where - testEquality SCTDirect SCTDirect = Just Refl - testEquality SCTGroup SCTGroup = Just Refl - testEquality SCTLocal SCTLocal = Just Refl - testEquality SCTContactRequest SCTContactRequest = Just Refl - testEquality SCTContactConnection SCTContactConnection = Just Refl - testEquality _ _ = Nothing - -data AChatType = forall c. ChatTypeI c => ACT (SChatType c) - -class ChatTypeI (c :: ChatType) where - chatTypeI :: SChatType c - -instance ChatTypeI 'CTDirect where chatTypeI = SCTDirect - -instance ChatTypeI 'CTGroup where chatTypeI = SCTGroup - -instance ChatTypeI 'CTLocal where chatTypeI = SCTLocal - -instance ChatTypeI 'CTContactRequest where chatTypeI = SCTContactRequest - -instance ChatTypeI 'CTContactConnection where chatTypeI = SCTContactConnection - -toChatType :: SChatType c -> ChatType -toChatType = \case - SCTDirect -> CTDirect - SCTGroup -> CTGroup - SCTLocal -> CTLocal - SCTContactRequest -> CTContactRequest - SCTContactConnection -> CTContactConnection - -aChatType :: ChatType -> AChatType -aChatType = \case - CTDirect -> ACT SCTDirect - CTGroup -> ACT SCTGroup - CTLocal -> ACT SCTLocal - CTContactRequest -> ACT SCTContactRequest - CTContactConnection -> ACT SCTContactConnection - -checkChatType :: forall t c c'. (ChatTypeI c, ChatTypeI c') => t c' -> Either String (t c) -checkChatType x = case testEquality (chatTypeI @c) (chatTypeI @c') of - Just Refl -> Right x - Nothing -> Left "bad chat type" - data SndMessage = SndMessage { msgId :: MessageId, sharedMsgId :: SharedMsgId, @@ -1369,8 +1383,6 @@ data CIModeration = CIModeration } deriving (Show) -$(JQ.deriveJSON (enumJSON $ dropPrefix "CT") ''ChatType) - instance ChatTypeI c => FromJSON (SChatType c) where parseJSON v = (\(ACT t) -> checkChatType t) . aChatType <$?> J.parseJSON v diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index a52133110a..505f73d9cd 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -14,10 +14,12 @@ module Simplex.Chat.Messages.CIContent where +import Control.Applicative ((<|>)) import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A +import qualified Data.ByteString.Lazy as LB import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -694,7 +696,13 @@ $(JQ.deriveJSON defaultJSON ''CIGroupInvitation) $(JQ.deriveJSON (enumJSON $ dropPrefix "CISCall") ''CICallStatus) -- platform specific -$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCI") ''JSONCIContent) +$(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "JCI") ''JSONCIContent) + +-- We only need this fallback for platform specific encoding to support remote desktop link +instance FromJSON JSONCIContent where + parseJSON v = + $(JQ.mkParseJSON (sumTypeJSON $ dropPrefix "JCI") ''JSONCIContent) v + <|> pure (JCIInvalidJSON MDRcv $ safeDecodeUtf8 $ LB.toStrict $ J.encode v) -- platform independent $(JQ.deriveJSON (singleFieldJSON $ dropPrefix "DBJCI") ''DBJSONCIContent) @@ -709,7 +717,11 @@ instance MsgDirectionI d => ToJSON (CIContent d) where toEncoding = J.toEncoding . jsonCIContent instance MsgDirectionI d => FromJSON (CIContent d) where - parseJSON v = (\(ACIContent _ c) -> checkDirection c) <$?> J.parseJSON v + parseJSON v = unwrap <$?> J.parseJSON v + where + unwrap = \case + ACIContent _ (CIInvalidJSON t) -> Right $ CIInvalidJSON @d t -- ignoring direction in ACIContent - it may be incorrect from JSONCIContent parser fallback + ACIContent _ c -> checkDirection c -- platform independent dbParseACIContent :: Text -> Either String ACIContent diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index f9427d6ab2..2ae9e106cb 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -75,7 +75,7 @@ remoteFilesFolder = "simplex_v1_files" -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 4, 0, 5, 1] +minRemoteCtrlVersion = AppVersion [6, 4, 1, 0] -- when acting as controller minRemoteHostVersion :: AppVersion diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 54da62e9bb..e94bedea8e 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -2147,7 +2147,7 @@ getMatchingContacts db vr user@User {userId} Contact {contactId, profile = Local WHERE ct.user_id = ? AND ct.contact_id != ? AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0 AND p.display_name = ? AND p.full_name = ? - AND p.short_descr IS ? AND p.image IS ? + AND p.short_descr IS NOT DISTINCT FROM ? AND p.image IS NOT DISTINCT FROM ? |] getMatchingMembers :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO [GroupMember] @@ -2164,7 +2164,7 @@ getMatchingMembers db vr user@User {userId} Contact {profile = LocalProfile {dis WHERE m.user_id = ? AND m.contact_id IS NULL AND m.member_category != ? AND p.display_name = ? AND p.full_name = ? - AND p.short_descr IS ? AND p.image IS ? + AND p.short_descr IS NOT DISTINCT FROM ? AND p.image IS NOT DISTINCT FROM ? |] getMatchingMemberContacts :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> IO [Contact] @@ -2181,7 +2181,7 @@ getMatchingMemberContacts db vr user@User {userId} GroupMember {memberProfile = WHERE ct.user_id = ? AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0 AND p.display_name = ? AND p.full_name = ? - AND p.short_descr IS ? AND p.image IS ? + AND p.short_descr IS NOT DISTINCT FROM ? AND p.image IS NOT DISTINCT FROM ? |] createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> ContactOrMember -> ExceptT StoreError IO (Probe, Int64) 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 968697b1c3..ab35c46aa2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3230,7 +3230,7 @@ Query: WHERE ct.user_id = ? AND ct.contact_status = ? AND ct.deleted = 0 AND ct.is_user = 0 AND p.display_name = ? AND p.full_name = ? - AND p.short_descr IS ? AND p.image IS ? + AND p.short_descr IS NOT DISTINCT FROM ? AND p.image IS NOT DISTINCT FROM ? Plan: SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) @@ -3360,7 +3360,7 @@ Query: WHERE m.user_id = ? AND m.contact_id IS NULL AND m.member_category != ? AND p.display_name = ? AND p.full_name = ? - AND p.short_descr IS ? AND p.image IS ? + AND p.short_descr IS NOT DISTINCT FROM ? AND p.image IS NOT DISTINCT FROM ? Plan: SEARCH m USING INDEX idx_group_members_user_id (user_id=?) diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 5731be7b0b..8a37a4c477 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -164,8 +164,8 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} Cha case (chatDirNtf u chat chatDir (isUserMention ci), itemStatus) of (True, CISRcvNew) -> do let itemId = chatItemId' ci - chatRef = chatInfoToRef chat - void $ runReaderT (execChatCommand' (APIChatItemsRead chatRef [itemId]) 0) cc + chatRef_ = chatInfoToRef chat + forM_ chatRef_ $ \chatRef -> runReaderT (execChatCommand' (APIChatItemsRead chatRef [itemId]) 0) cc _ -> pure () logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s getRemoteUser rhId = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index c3072662da..342b7b8a6c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -325,11 +325,13 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte testViewChats chats = [sShow $ map toChatView chats] where toChatView :: AChat -> (Text, Text, Maybe ConnStatus) - toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn) - toChatView (AChat _ (Chat (GroupChat GroupInfo {membership, localDisplayName} _scopeInfo) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) - toChatView (AChat _ (Chat (LocalChat _) items _)) = ("*", toCIPreview items Nothing, Nothing) - toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing) - toChatView (AChat _ (Chat (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus) + toChatView (AChat _ (Chat cInfo items _)) = case cInfo of + DirectChat Contact {localDisplayName, activeConn} -> ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn) + GroupChat GroupInfo {membership, localDisplayName} _scopeInfo -> ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) + LocalChat _ -> ("*", toCIPreview items Nothing, Nothing) + ContactRequest UserContactRequest {localDisplayName} -> ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing) + ContactConnection PendingContactConnection {pccConnId, pccConnStatus} -> (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus) + CInfoInvalidJSON {} -> ("invalid chat info", "", Nothing) toCIPreview :: [CChatItem c] -> Maybe GroupMember -> Text toCIPreview (ci : _) membership_ = testViewItem ci membership_ toCIPreview _ _ = "" @@ -720,6 +722,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa context = maybe [] forwardedFrom itemForwarded ContactRequest {} -> [] ContactConnection {} -> [] + CInfoInvalidJSON {} -> ["invalid chat info"] withItemDeleted item = case chatItemDeletedText ci (chatInfoMembership chat) of Nothing -> item Just t -> item <> styled (colored Red) (" [" <> t <> "]") @@ -917,6 +920,7 @@ viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md (_, CIDirectSnd) -> [sentText] (_, CIGroupSnd) -> [sentText] (_, CILocalSnd) -> [sentText] + (CInfoInvalidJSON {}, _) -> [] where view from msg | showReactions = viewReceivedReaction from msg reactionText ts tz sentAt @@ -1023,6 +1027,7 @@ viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of LocalChat _ -> ["notes: all messages are removed"] ContactRequest _ -> [] ContactConnection _ -> [] + CInfoInvalidJSON {} -> [] viewContactsList :: [Contact] -> [StyledString] viewContactsList = diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 12a63d2333..9c19923772 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -529,6 +529,7 @@ smpServerCfg = newQueueBasicAuth = Nothing, -- Just "server_password", controlPortUserAuth = Nothing, controlPortAdminAuth = Nothing, + dailyBlockQueueQuota = 20, messageExpiration = Just defaultMessageExpiration, expireMessagesOnStart = False, idleQueueInterval = defaultIdleQueueInterval,