From 66be59753a0beb1821ff0f845bf01b7c980d0b7f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 12 Jul 2025 20:27:43 +0100 Subject: [PATCH 01/20] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index fc78a3532d..da26bf8843 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -178,8 +178,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.5.1-H9b1PUqJfdC45BY0VzYHX-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8.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 */; }; @@ -543,8 +543,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.5.1-H9b1PUqJfdC45BY0VzYHX-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8.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 = ""; }; @@ -704,8 +704,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -790,8 +790,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8.a */, ); path = Libraries; sourceTree = ""; From 9d7ce4801611291273539b9345b2a9f6c7740c11 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 12 Jul 2025 22:30:24 +0100 Subject: [PATCH 02/20] 6.4-beta.6: ios 289, android 302, desktop 111 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 ++++++++++---------- apps/multiplatform/gradle.properties | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index da26bf8843..31fd22901b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1995,7 +1995,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 288; + CURRENT_PROJECT_VERSION = 289; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2045,7 +2045,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 288; + CURRENT_PROJECT_VERSION = 289; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2087,7 +2087,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 288; + CURRENT_PROJECT_VERSION = 289; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2107,7 +2107,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 288; + CURRENT_PROJECT_VERSION = 289; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2132,7 +2132,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 288; + CURRENT_PROJECT_VERSION = 289; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2169,7 +2169,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 288; + CURRENT_PROJECT_VERSION = 289; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2206,7 +2206,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 288; + CURRENT_PROJECT_VERSION = 289; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2257,7 +2257,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 288; + CURRENT_PROJECT_VERSION = 289; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2308,7 +2308,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 288; + CURRENT_PROJECT_VERSION = 289; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2342,7 +2342,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 288; + CURRENT_PROJECT_VERSION = 289; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 506d47d36b..40d653f80f 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.4-beta.5 -android.version_code=301 +android.version_name=6.4-beta.6 +android.version_code=302 android.bundle=false -desktop.version_name=6.4-beta.5 -desktop.version_code=110 +desktop.version_name=6.4-beta.6 +desktop.version_code=111 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From a25c44494e5b7da9ee50a5274983fc036ddb8ffc Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 13 Jul 2025 11:46:51 +0100 Subject: [PATCH 03/20] core: revert member filter change, fix text (#6062) --- src/Simplex/Chat/Store/Groups.hs | 4 ++-- tests/ChatTests/Direct.hs | 6 +++--- tests/ChatTests/Profiles.hs | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 4b1b72ff9d..8a4c5461b6 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1729,7 +1729,7 @@ getIntroducedGroupMemberIds db invitee = getForwardIntroducedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember] getForwardIntroducedMembers db vr user invitee highlyAvailable = do memberIds <- map fromOnly <$> query - filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds + rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds where mId = groupMemberId' invitee query @@ -1769,7 +1769,7 @@ getForwardIntroducedModerators db vr user@User {userContactId} invitee = do getForwardInvitedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember] getForwardInvitedMembers db vr user forwardMember highlyAvailable = do memberIds <- map fromOnly <$> query - filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds + rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds where mId = groupMemberId' forwardMember query diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index d4ef4674bb..b64bfb8b28 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1782,14 +1782,14 @@ testMultipleUserAddresses = cLinkAlisa <- getContactLink alice True bob ##> ("/c " <> cLinkAlisa) alice <#? bob - alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", "Audio/video calls: enabled"), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")]) + alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", "Audio/video calls: enabled"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")]) alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request, you can send messages to contact" concurrently_ (bob <## "alisa: contact is connected") (alice <## "bob (Bob): contact is connected") threadDelay 100000 - alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", lastChatFeature), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")]) + alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", lastChatFeature), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")]) alice <##> bob bob #> "@alice hey alice" @@ -1820,7 +1820,7 @@ testMultipleUserAddresses = (cath <## "alisa: contact is connected") (alice <## "cath (Catherine): contact is connected") threadDelay 100000 - alice #$> ("/_get chats 2 pcc=on", chats, [("@cath", lastChatFeature), ("@bob", "hey"), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")]) + alice #$> ("/_get chats 2 pcc=on", chats, [("@cath", lastChatFeature), ("@bob", "hey"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")]) alice <##> cath -- first user doesn't have cath as contact diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index e16ffc4929..90160a3e51 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -3682,8 +3682,8 @@ testShortLinkChangePreparedContactUser ps@TestParams {largeLinkData} = testChatC alice @@@ [("@robert", "hey")] alice `hasContactProfiles` ["alice", "robert"] - bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")]) - bob `hasContactProfiles` ["robert", "alice", "SimpleX Chat team", "SimpleX-Status"] + bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")]) + bob `hasContactProfiles` ["robert", "alice", "Ask SimpleX Team", "SimpleX Status"] bob ##> "/user bob" showActiveUser bob "bob (Bob)" bob @@@ [] @@ -3741,8 +3741,8 @@ testShortLinkChangePreparedContactUserDuplicate ps@TestParams {largeLinkData} = alice @@@ [("@robert", "hey"), ("@robert_1", "hey")] alice `hasContactProfiles` ["alice", "robert", "robert"] - bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@alice_1", "hey"), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")]) - bob `hasContactProfiles` ["robert", "alice", "alice", "SimpleX Chat team", "SimpleX-Status"] + bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@alice_1", "hey"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")]) + bob `hasContactProfiles` ["robert", "alice", "alice", "Ask SimpleX Team", "SimpleX Status"] bob ##> "/user bob" showActiveUser bob "bob (Bob)" bob @@@ [] @@ -3835,8 +3835,8 @@ testShortLinkChangePreparedGroupUser ps@TestParams {largeLinkData} = testChatCfg alice @@@ [("#team", "3"), ("@cath","sent invitation to join group team as admin")] alice `hasContactProfiles` ["alice", "cath", "robert"] - bob #$> ("/_get chats 2 pcc=on", chats, [("#team", "3"), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")]) - bob `hasContactProfiles` ["robert", "alice", "cath", "SimpleX Chat team", "SimpleX-Status"] + bob #$> ("/_get chats 2 pcc=on", chats, [("#team", "3"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")]) + bob `hasContactProfiles` ["robert", "alice", "cath", "Ask SimpleX Team", "SimpleX Status"] cath @@@ [("#team", "3"), ("@alice","received invitation to join group team as admin")] cath `hasContactProfiles` ["cath", "alice", "robert"] bob ##> "/user bob" @@ -3949,7 +3949,7 @@ testShortLinkChangePreparedGroupUserDuplicate ps@TestParams {largeLinkData} = te alice @@@ [("#team", "7"), ("@cath","sent invitation to join group team as admin")] alice `hasContactProfiles` ["alice", "cath", "robert", "robert"] - bob `hasContactProfiles` ["robert", "robert", "robert", "alice", "alice", "cath", "cath", "SimpleX Chat team", "SimpleX-Status"] + bob `hasContactProfiles` ["robert", "robert", "robert", "alice", "alice", "cath", "cath", "Ask SimpleX Team", "SimpleX Status"] cath @@@ [("#team", "7"), ("@alice","received invitation to join group team as admin")] cath `hasContactProfiles` ["cath", "alice", "robert", "robert"] bob ##> "/user bob" From ffacdcc8cbb077e21c2ac170666fd2cd83f0cd3c Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 13 Jul 2025 11:47:25 +0100 Subject: [PATCH 04/20] faq: multi-device support (#6063) * faq: multi-device support * update --- docs/FAQ.md | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/FAQ.md b/docs/FAQ.md index 0d0426d7c9..fa9a4487d4 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -32,6 +32,7 @@ revision: 23.04.2024 [Privacy and security](#privacy-and-security) - [Does SimpleX support post quantum cryptography?](#does-simplex-support-post-quantum-cryptography) +- [Why can't I use the same profile on different devices?](#why-cant-I-use-the-same-profile-on-different-devices) - [What user data can be provided on request?](#what-user-data-can-be-provided-on-request) - [Does SimpleX protect my IP address?](#does-simplex-protect-my-ip-address) - [Doesn't private message routing reinvent Tor?](#doesnt-private-message-routing-reinvent-tor) @@ -53,15 +54,15 @@ Please check our [Groups Directory](./DIRECTORY.md) in the first place. You migh Database is essential for SimpleX Chat to function properly. In comparison to centralized messaging providers, it is _the user_ who is responsible for taking care of their data. On the other hand, user is sure that _nobody but them_ has access to it. Please read more about it: [Database](./guide/managing-data.md). -### Can I send files over SimpleX? +### Can I send files over SimpleX? Of course! While doing so, you are using a _state-of-the-art_ protocol that greatly reduces metadata leaks. Please read more about it: [XFTP Protocol](../blog/20230301-simplex-file-transfer-protocol.md). ### What’s incognito profile? -This feature is unique to SimpleX Chat – it is independent from chat profiles. +This feature is unique to SimpleX Chat – it is independent from chat profiles. -When "Incognito Mode” is turned on, your currently chosen profile name and image are hidden from your new contacts. It allows anonymous connections with other people without any shared data – when you make new connections or join groups via a link a new random profile name will be generated for each connection. +When "Incognito Mode” is turned on, your currently chosen profile name and image are hidden from your new contacts. It allows anonymous connections with other people without any shared data – when you make new connections or join groups via a link a new random profile name will be generated for each connection. ### How do invitations work? @@ -256,6 +257,29 @@ You can resolve it by deleting the app's database: (WARNING: this results in del Yes! Please read more about quantum resistant encryption is added to SimpleX Chat and about various properties of end-to-end encryption in [this post](../blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md). +### Why can't I use the same profile on different devices? + +SimpleX Chat apps support [linking of mobile and desktop apps](https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html#link-mobile-and-desktop-apps-via-secure-quantum-resistant-protocol) via secure quantum-resistant protocol. It allows using the profile on your mobile device from desktop clients. + +Seamlessly and securely using the same profile from two or more devices is a complex and unsolved problem. All apps that provide multi-device support do so at a cost of compromising security of end-to-end encryption. E.g., Session removed the Double Ratchet algorithm entirely to enable multi-device support, sacrificing forward secrecy. Signal provides multi-device support with Double Ratchet algorithm, but by [compromising its "break-in recovery" property](https://eprint.iacr.org/2021/626.pdf) (aka post-compromise security). + +To the best of our knowledge there is no end-to-end encrypted messenger that solved this problem without compromising security, but we believe that the solution is possible. We have considered several approaches: + +1. Convert each direct conversation into a group, where each device participates as a member. This is the approach that Signal and WhatsApp use, and while Signal implementation does not protect from a temporary compromise of long-term identity key (break-in recovery), such protection is possible. The downside of this approach is that the contacts and groups you participate in would know which device you use. Another possible attack is to send different messages to different devices, or to send messages to some devices but not to the others. This could lead to message history inconsistency or enable targeted attacks. + +2. Store the state of the Double Ratchet algorithm for each conversation in an encrypted container on the server, allowing concurrent access and modification by each device for encrypting and decrypting messages. We did not see this approach used in any of the messaging apps, but it is technically viable. This approach has no downsides of the first, but it would increase the time it takes to send and to receive messages, as each message would require additional access to the server. + +3. "Thin client" approach when user profile is stored on the server. The main challenge with this approach is to prevent the server knowing who connects to whom. + +Whichever approach we choose for multi-device support, it requires careful design and implementation, and there is no existing secure solution to copy from. While we value usability very highly, we will not be improving usability in a way that compromises users' security. We will take a slower path of designing and implementing a solution for multi-device that achieves a better trade-off between usability and security than currently offered. + +In the meantime, here are several secure options to enhance usability: +- link mobile profiles with desktop app. It does not compromise security in any way. +- create small groups with trusted contacts. These contacts would still know which device you use when you send the message, but it won't be shared with all contacts and groups you participate in. This approach is also secure, and it prevents devices being added to the conversation without user noticing. +- use "[business address](https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html#business-chats)" - the app would create a new small group with everybody who connects to you via your address, and you will be able to add your other devices to these groups. + +While these approaches are not as convenient as seamless multi-device support offered by other apps, they also do not compromise security to achieve that convenience. + ### What user data can be provided on request? Our objective is to consistently ensure that no user data and absolute minimum of the metadata required for the network to function is available for disclosure by any infrastructure operators, under any circumstances. From caf3d55af824c37cc1c3d84e7b37fba61f21a258 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 13 Jul 2025 11:53:35 +0100 Subject: [PATCH 05/20] faq: fix link --- docs/FAQ.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/FAQ.md b/docs/FAQ.md index fa9a4487d4..890fa608c0 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -32,7 +32,7 @@ revision: 23.04.2024 [Privacy and security](#privacy-and-security) - [Does SimpleX support post quantum cryptography?](#does-simplex-support-post-quantum-cryptography) -- [Why can't I use the same profile on different devices?](#why-cant-I-use-the-same-profile-on-different-devices) +- [Why can't I use the same profile on different devices?](#why-cant-i-use-the-same-profile-on-different-devices) - [What user data can be provided on request?](#what-user-data-can-be-provided-on-request) - [Does SimpleX protect my IP address?](#does-simplex-protect-my-ip-address) - [Doesn't private message routing reinvent Tor?](#doesnt-private-message-routing-reinvent-tor) From dd3943d994a8d7f113e18fd315689d4c0ecfbe7c Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 14 Jul 2025 08:02:29 +0100 Subject: [PATCH 06/20] core, ios: allow moderators to delete messages and block members (#6064) * core: allow moderators to delete messages and block members * ios: moderator --- apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift | 4 ++-- .../ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift | 2 +- .../Shared/Views/Chat/SelectableChatItemToolbars.swift | 2 +- apps/ios/SimpleXChat/ChatTypes.swift | 8 ++++---- src/Simplex/Chat/Library/Commands.hs | 5 ++--- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 0fd4a86ece..376e83c2d8 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -426,7 +426,7 @@ struct GroupChatInfoView: View { if user { v - } else if groupInfo.membership.memberRole >= .admin { + } else if groupInfo.membership.memberRole >= .moderator { // TODO if there are more actions, refactor with lists of swipeActions let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo) let canRemove = member.canBeRemoved(groupInfo: groupInfo) @@ -469,7 +469,7 @@ struct GroupChatInfoView: View { .foregroundColor(theme.colors.secondary) } else { let role = member.memberRole - if [.owner, .admin, .observer].contains(role) { + if [.owner, .admin, .moderator, .observer].contains(role) { Text(member.memberRole.text) .foregroundColor(theme.colors.secondary) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 725b5a61fa..2057b9b43c 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -180,7 +180,7 @@ struct GroupMemberInfoView: View { } } - if groupInfo.membership.memberRole >= .admin { + if groupInfo.membership.memberRole >= .moderator { adminDestructiveSection(member) } else { nonAdminBlockSection(member) diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift index e397970acd..4855c3ca8d 100644 --- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -146,7 +146,7 @@ struct SelectedItemsBottomToolbar: View { private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool { return switch chatInfo { case let .group(groupInfo, _): - groupInfo.membership.memberRole >= .admin + groupInfo.membership.memberRole >= .moderator default: false } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index cd687f619c..ea61f125f2 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2419,8 +2419,8 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public func canBlockForAll(groupInfo: GroupInfo) -> Bool { let userRole = groupInfo.membership.memberRole - return memberStatus != .memRemoved && memberStatus != .memLeft && memberRole < .admin - && userRole >= .admin && userRole >= memberRole && groupInfo.membership.memberActive + return memberStatus != .memRemoved && memberStatus != .memLeft && memberRole < .moderator + && userRole >= .moderator && userRole >= memberRole && groupInfo.membership.memberActive } public var canReceiveReports: Bool { @@ -2980,12 +2980,12 @@ public struct ChatItem: Identifiable, Decodable, Hashable { switch (chatInfo, chatDir) { case let (.group(groupInfo, _), .groupRcv(groupMember)): let m = groupInfo.membership - return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole && meta.itemDeleted == nil + return m.memberRole >= .moderator && m.memberRole >= groupMember.memberRole && meta.itemDeleted == nil ? (groupInfo, groupMember) : nil case let (.group(groupInfo, _), .groupSnd): let m = groupInfo.membership - return m.memberRole >= .admin ? (groupInfo, nil) : nil + return m.memberRole >= .moderator ? (groupInfo, nil) : nil default: return nil } } diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 1f1f114a39..c276d0a6d1 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1672,8 +1672,7 @@ processChatCommand vr nm = \case gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId m <- withFastStore $ \db -> getGroupMember db vr user gId mId let GroupInfo {membership = GroupMember {memberRole = membershipRole}} = gInfo - -- TODO GRModerator when most users migrate - when (membershipRole >= GRAdmin) $ throwChatError $ CECantBlockMemberForSelf gInfo m showMessages + when (membershipRole >= GRModerator) $ throwChatError $ CECantBlockMemberForSelf gInfo m showMessages let settings = (memberSettings m) {showMessages} processChatCommand vr nm $ APISetMemberSettings gId mId settings ContactInfo cName -> withContactName cName APIContactInfo @@ -3200,7 +3199,7 @@ processChatCommand vr nm = \case delGroupChatItemsForMembers :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [CChatItem 'CTGroup] -> CM [ChatItemDeletion] delGroupChatItemsForMembers user gInfo chatScopeInfo ms items = do assertDeletable gInfo items - assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate + assertUserGroupRole gInfo GRModerator let msgMemIds = itemsMsgMemIds gInfo items events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId (Just memId) $ toMsgScope gInfo <$> chatScopeInfo) msgMemIds mapM_ (sendGroupMessages_ user gInfo ms) events From ca672bbc776e836b6e18c8eb3ba7949304a05c4f Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 14 Jul 2025 11:02:19 +0100 Subject: [PATCH 07/20] ui: do not show subscription percentage when there are no conections and no session (#6066) * ui: do not show subscription percentage when there are no conections and no session * show % sign when share is not known yet --- .../Views/ChatList/ServersSummaryView.swift | 48 +++++++++---------- .../views/chatlist/ServersSummaryView.kt | 2 +- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift index 8b0a8af888..ee7605dbd2 100644 --- a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift +++ b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift @@ -412,7 +412,7 @@ struct SubscriptionStatusIndicatorView: View { var hasSess: Bool var body: some View { - let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage( + let (color, variableValue, opacity) = subscriptionStatusInfo( online: m.networkInfo.online, usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil, subs: subs, @@ -431,25 +431,19 @@ struct SubscriptionStatusIndicatorView: View { struct SubscriptionStatusPercentageView: View { @EnvironmentObject var m: ChatModel - @EnvironmentObject var theme: AppTheme var subs: SMPServerSubs var hasSess: Bool var body: some View { - let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage( - online: m.networkInfo.online, - usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil, - subs: subs, - hasSess: hasSess, - primaryColor: theme.colors.primary - ) - Text(verbatim: "\(Int(floor(statusPercent * 100)))%") + let statusPercent = subscriptionStatusPercent(online: m.networkInfo.online, subs: subs, hasSess: hasSess) + let percentText: String = subs.total > 0 || hasSess ? "\(Int(floor(statusPercent * 100)))%" : "%" + Text(percentText) .foregroundColor(.secondary) .font(.caption) } } -func subscriptionStatusColorAndPercentage(online: Bool, usesProxy: Bool, subs: SMPServerSubs, hasSess: Bool, primaryColor: Color) -> (Color, Double, Double, Double) { +func subscriptionStatusInfo(online: Bool, usesProxy: Bool, subs: SMPServerSubs, hasSess: Bool, primaryColor: Color) -> (Color, Double, Double) { func roundedToQuarter(_ n: Double) -> Double { n >= 1 ? 1 : n <= 0 ? 0 @@ -457,26 +451,28 @@ func subscriptionStatusColorAndPercentage(online: Bool, usesProxy: Bool, subs: S } let activeColor: Color = usesProxy ? .indigo : primaryColor - let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0) + let noConnColorAndPercent: (Color, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1) let activeSubsRounded = roundedToQuarter(subs.shareOfActive) return !online ? noConnColorAndPercent - : ( - subs.total == 0 && !hasSess - ? (activeColor, 0, 0.33, 0) // On freshly installed app (without chats) and on app start - : ( - subs.ssActive == 0 - ? ( - hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) : noConnColorAndPercent - ) - : ( // ssActive > 0 - hasSess - ? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) - : (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error - ) - ) + : subs.total == 0 && !hasSess + ? (activeColor, 0, 0.33) // On freshly installed app (without chats) and on app start + : subs.ssActive == 0 + ? ( + hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive) : noConnColorAndPercent ) + : ( // ssActive > 0 + hasSess + ? (activeColor, activeSubsRounded, subs.shareOfActive) + : (.orange, activeSubsRounded, subs.shareOfActive) // This would mean implementation error + ) +} + +func subscriptionStatusPercent(online: Bool, subs: SMPServerSubs, hasSess: Bool) -> Double { + online && (hasSess || (subs.total > 0 && subs.ssActive > 0)) + ? subs.shareOfActive + : 0 } struct SMPServerSummaryView: View { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index acbc72ff48..55ac9c8810 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -120,7 +120,7 @@ fun SubscriptionStatusIndicatorView(subs: SMPServerSubs, hasSess: Boolean, leadi val netCfg = rememberUpdatedState(chatModel.controller.getNetCfg()) val statusColorAndPercentage = subscriptionStatusColorAndPercentage(chatModel.networkInfo.value.online, netCfg.value.socksProxy, subs, hasSess) val pref = remember { chatModel.controller.appPrefs.networkShowSubscriptionPercentage } - val percentageText = "${(floor(statusColorAndPercentage.statusPercent * 100)).toInt()}%" + val percentageText = if (subs.total > 0 || hasSess) "${(floor(statusColorAndPercentage.statusPercent * 100)).toInt()}%" else "%" Row( verticalAlignment = Alignment.CenterVertically, From ed6ef02eeba471a1887691ff3eec15b1257823e3 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 14 Jul 2025 10:52:38 +0000 Subject: [PATCH 08/20] core: add group forwarding tests (#6067) --- src/Simplex/Chat/Library/Commands.hs | 1 - tests/ChatTests/Groups.hs | 110 ++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c276d0a6d1..fc2ffe5025 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3161,7 +3161,6 @@ processChatCommand vr nm = \case Nothing -> do setGroupLinkData' let recipients = filter memberCurrentOrPending ms - liftIO $ putStrLn $ "about to sendGroupMessage to " <> show (length recipients) sendGroupMessage user g' Nothing recipients (XGrpInfo p') where setGroupLinkData' :: CM () diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 23837ad597..a3e298219d 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -204,10 +204,9 @@ chatGroupTests = do it "should forward messages inside support scope" testScopedSupportForward it "should forward messages inside support scope while member is in review" testScopedSupportForwardWhileReview it "should not forward messages from support to main scope" testScopedSupportDontForward - -- TODO test messages are not forwarded between support scopes (1 in review, 1 not? combinations?) + it "should forward group wide message (x.grp.info) to all members, including in review" testScopedSupportForwardAll + it "should not forward messages between support scopes" testScopedSupportDontForwardBetweenScopes it "should forward file inside support scope" testScopedSupportForwardFile - -- TODO test files are forwarded inside support scope while member is in review - -- TODO test group events directed to all (e.g. XGrpInfo) are forwarded to support scope member while in review it "should send messages to admins and members" testSupportCLISendCommand it "should correctly maintain unread stats for support chats on reading chat items" testScopedSupportUnreadStatsOnRead it "should correctly maintain unread stats for support chats on deleting chat items" testScopedSupportUnreadStatsOnDelete @@ -7163,6 +7162,111 @@ testScopedSupportDontForward = cath #> "#team (support) 4" [alice, dan] *<# "#team (support: cath) cath> 4" +testScopedSupportForwardAll :: HasCallStack => TestParams -> IO () +testScopedSupportForwardAll = + testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $ + \alice bob cath dan eve -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GROwner) + + alice ##> "/set admission review #team all" + alice <## "changed member admission rules" + concurrentlyN_ + [ do + bob <## "alice updated group #team:" + bob <## "changed member admission rules", + do + cath <## "alice updated group #team:" + cath <## "changed member admission rules", + do + dan <## "alice updated group #team:" + dan <## "changed member admission rules" + ] + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + eve ##> ("/c " <> gLink) + eve <## "connection request sent!" + alice <## "eve (Eve): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: eve connected and pending review", + eve + <### [ "#team: alice accepted you to the group, pending review", + "#team: joining the group...", + "#team: you joined the group, connecting to group moderators for admission to group", + "#team: member dan (Daniel) is connected" + ], + do + dan <## "#team: alice added eve (Eve) to the group (connecting and pending review...), use /_accept member #1 5 to accept member" + dan <## "#team: new member eve is connected and pending review, use /_accept member #1 5 to accept member" + ] + + setupGroupForwarding alice bob dan + setupGroupForwarding alice dan eve + + -- messages are forwarded in main scope between bob and dan + bob #> "#team 1" + [alice, cath] *<# "#team bob> 1" + dan <# "#team bob> 1 [>>]" + + dan #> "#team 2" + [alice, cath] *<# "#team dan> 2" + bob <# "#team dan> 2 [>>]" + + -- messages are forwarded in support scope between dan and eve + eve #> "#team (support) 3" + alice <# "#team (support: eve) eve> 3" + dan <# "#team (support: eve) eve> 3 [>>]" + + dan #> "#team (support: eve) 4" + alice <# "#team (support: eve) dan> 4" + eve <# "#team (support) dan> 4 [>>]" + + -- x.grp.info is forwarded from dan to both bob and eve + dan ##> "/gp team my_team" + dan <## "changed to #my_team" + concurrentlyN_ + [ do + alice <## "dan updated group #team:" + alice <## "changed to #my_team", + do + bob <## "dan updated group #team:" + bob <## "changed to #my_team", + do + cath <## "dan updated group #team:" + cath <## "changed to #my_team", + do + eve <## "dan updated group #team:" + eve <## "changed to #my_team" + ] + +testScopedSupportDontForwardBetweenScopes :: HasCallStack => TestParams -> IO () +testScopedSupportDontForwardBetweenScopes = + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GRModerator) + setupGroupForwarding alice bob cath + + -- messages are forwarded in main scope + bob #> "#team 1" + [alice, dan] *<# "#team bob> 1" + cath <# "#team bob> 1 [>>]" + + cath #> "#team 2" + [alice, dan] *<# "#team cath> 2" + bob <# "#team cath> 2 [>>]" + + -- messages not forwarded between support scopes + bob #> "#team (support) 3" + alice <# "#team (support: bob) bob> 3" + dan <# "#team (support: bob) bob> 3" + + cath #> "#team (support) 4" + alice <# "#team (support: cath) cath> 4" + dan <# "#team (support: cath) cath> 4" + + bob #> "#team (support) 5" + alice <# "#team (support: bob) bob> 5" + dan <# "#team (support: bob) bob> 5" + testScopedSupportForwardFile :: HasCallStack => TestParams -> IO () testScopedSupportForwardFile = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> withXFTPServer $ do From 45c6524c245a0b477944134958e5a28af70c3f9c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 14 Jul 2025 11:45:58 +0000 Subject: [PATCH 09/20] android: fix compose view being covered in reachable app toolbar without reachable chat toolbar mode (#6069) --- .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 2 +- .../simplex/common/views/chat/group/MemberSupportChatView.kt | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 444255a5fb..5f00a592b8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -989,7 +989,7 @@ fun ChatLayout( if (oneHandUI.value) { StatusBarBackground() } - Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Column(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { Box { if (selectedChatItems.value == null) { MemberSupportChatAppBar(chatsCtx, chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_, { ModalManager.end.closeModal() }, onSearchValueChanged) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt index 99e2e3198e..99565618f9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -56,6 +56,7 @@ fun MemberSupportChatAppBar( onSearchValueChanged: (String) -> Unit ) { val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state } + val chatBottomBar = remember { ChatController.appPrefs.chatBottomBar.state } val showSearch = rememberSaveable { mutableStateOf(false) } val onBackClicked = { if (!showSearch.value) { @@ -71,7 +72,7 @@ fun MemberSupportChatAppBar( navigationButton = { NavigationButtonBack(onBackClicked) }, title = { MemberSupportChatToolbarTitle(scopeMember_) }, onTitleClick = null, - onTop = !oneHandUI.value, + onTop = !oneHandUI.value || !chatBottomBar.value, showSearch = showSearch.value, onSearchValueChanged = onSearchValueChanged, buttons = { @@ -85,7 +86,7 @@ fun MemberSupportChatAppBar( navigationButton = { NavigationButtonBack(onBackClicked) }, fixedTitleText = stringResource(MR.strings.support_chat), onTitleClick = null, - onTop = !oneHandUI.value, + onTop = !oneHandUI.value || !chatBottomBar.value, showSearch = showSearch.value, onSearchValueChanged = onSearchValueChanged, buttons = { From ed536c9f80a1427442595b85c9d536c5e090ca9e Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 14 Jul 2025 12:46:15 +0100 Subject: [PATCH 10/20] ios: fix iOS 15 crash when importing chat database (#6068) --- apps/ios/Shared/Views/ChatList/UserPicker.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index c38ddfb1da..b1cd4015c6 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -97,7 +97,7 @@ struct UserPicker: View { } .onAppear { // This check prevents the call of listUsers after the app is suspended, and the database is closed. - if case .active = scenePhase { + if case .active = scenePhase, hasChatCtrl() { currentUser = m.currentUser?.userId Task { do { From df68edde56f1369656aef3a5fa422a4e929e0ff7 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 14 Jul 2025 16:30:15 +0100 Subject: [PATCH 11/20] ui: fix deleted contact showing as ready to connect (#6071) --- apps/ios/SimpleXChat/ChatTypes.swift | 4 ++-- .../commonMain/kotlin/chat/simplex/common/model/ChatModel.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ea61f125f2..b7a2aa2455 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1768,9 +1768,9 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } } public var active: Bool { get { contactStatus == .active } } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } - public var nextConnectPrepared: Bool { preparedContact != nil && (activeConn == nil || activeConn?.connStatus == .prepared) } + public var nextConnectPrepared: Bool { active && preparedContact != nil && (activeConn == nil || activeConn?.connStatus == .prepared) } public var profileChangeProhibited: Bool { activeConn != nil } - public var nextAcceptContactRequest: Bool { contactRequestId != nil && (activeConn == nil || activeConn?.connStatus == .new) } + public var nextAcceptContactRequest: Bool { active && contactRequestId != nil && (activeConn == nil || activeConn?.connStatus == .new) } public var sendMsgToConnect: Bool { nextSendGrpInv || nextConnectPrepared } public var displayName: String { localAlias == "" ? profile.displayName : localAlias } public var fullName: String { get { profile.fullName } } 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 7c2a359107..588ab17cd2 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 @@ -1743,9 +1743,9 @@ data class Contact( val active get() = contactStatus == ContactStatus.Active override val nextConnect get() = sendMsgToConnect val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent - override val nextConnectPrepared get() = preparedContact != null && (activeConn == null || activeConn.connStatus == ConnStatus.Prepared) + override val nextConnectPrepared get() = active && preparedContact != null && (activeConn == null || activeConn.connStatus == ConnStatus.Prepared) override val profileChangeProhibited get() = activeConn != null - val nextAcceptContactRequest get() = contactRequestId != null && (activeConn == null || activeConn.connStatus == ConnStatus.New) + val nextAcceptContactRequest get() = active && contactRequestId != null && (activeConn == null || activeConn.connStatus == ConnStatus.New) val sendMsgToConnect get() = nextSendGrpInv || nextConnectPrepared override val incognito get() = contactConnIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { From 1e73eb512a8e51fa71f43bac186ec1c4d54464b3 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 14 Jul 2025 22:37:39 +0100 Subject: [PATCH 12/20] core: fix connection plans to allow re-connecting to address after the contact with address in profile was deleted (#6073) --- src/Simplex/Chat/Library/Commands.hs | 8 +++--- tests/ChatTests/Profiles.hs | 41 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index fc2ffe5025..328b59769f 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3412,8 +3412,8 @@ processChatCommand vr nm = \case Nothing -> do (cReq, cData) <- getShortLinkConnReq user l' withFastStore' (\db -> getContactWithoutConnViaShortAddress db vr user l') >>= \case - Just ct' -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct')) - Nothing -> do + Just ct' | not (contactDeleted ct') -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct')) + _ -> do contactSLinkData_ <- liftIO $ decodeShortLinkData cData plan <- contactRequestPlan user cReq contactSLinkData_ pure (con cReq, plan) @@ -3486,8 +3486,8 @@ processChatCommand vr nm = \case withFastStore' (\db -> getContactConnEntityByConnReqHash db vr user cReqHashes) >>= \case Nothing -> withFastStore' (\db -> getContactWithoutConnViaAddress db vr user cReqSchemas) >>= \case - Nothing -> pure $ CPContactAddress (CAPOk contactSLinkData_) - Just ct -> pure $ CPContactAddress (CAPContactViaAddress ct) + Just ct | not (contactDeleted ct) -> pure $ CPContactAddress (CAPContactViaAddress ct) + _ -> pure $ CPContactAddress (CAPOk contactSLinkData_) Just (RcvDirectMsgConnection Connection {connStatus} Nothing) | connStatus == ConnPrepared -> pure $ CPContactAddress (CAPOk contactSLinkData_) | otherwise -> pure $ CPContactAddress CAPConnectingConfirmReconnect diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 90160a3e51..c59f06c473 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -124,6 +124,7 @@ shortLinkTests largeLinkData = do else it "prepare contact with a long name in profile" testShortLinkInvitationLongName it "prepare contact via invitation and retry connecting" testShortLinkInvitationConnectRetry it "prepare contact using address short link data and connect" testShortLinkAddressPrepareContact + it "address connect plan after contact is deleted but conversation kept" testShortLinkAddressDeleteContact it "prepare contact via invitation and connect after it is deleted" testShortLinkDeletedInvitation it "prepare contact via address and connect after it is deleted" testShortLinkDeletedAddress it "prepare contact via address and connect with retry after error" testShortLinkAddressConnectRetry @@ -3138,6 +3139,46 @@ testShortLinkAddressPrepareContact ps@TestParams {largeLinkData} = testChatCfg2 bob <## "contact address: ok to connect" void $ getTermLine bob +testShortLinkAddressDeleteContact :: HasCallStack => TestParams -> IO () +testShortLinkAddressDeleteContact ps@TestParams {largeLinkData} = testChatCfg2 testCfg {largeLinkData} aliceProfile bobProfile test ps + where + test alice bob = do + alice ##> "/ad" + (shortLink, fullLink) <- getContactLinks alice True + alice ##> "/pa on" + 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 <> " " <> contactSLinkData) + bob <## "alice: contact is prepared" + bob ##> "/_connect contact @2 text hello" + bob + <### [ "alice: connection started", + WithTime "@alice hello" + ] + alice + <### [ "bob (Bob) wants to connect to you!", + WithTime "bob> hello" + ] + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + unless largeLinkData $ + bob <## "contact alice updated bio: Alice" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice <##> bob + threadDelay 250000 + bob ##> "/d alice entity" + bob <## "alice: contact is deleted" + alice <## "bob (Bob) deleted contact with you" + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "contact address: ok to connect" + void $ getTermLine bob + testShortLinkDeletedInvitation :: HasCallStack => TestParams -> IO () testShortLinkDeletedInvitation ps@TestParams {largeLinkData} = testChatCfg2 testCfg {largeLinkData} aliceProfile bobProfile test ps where From 0a722cbe2a8407d1d08f37ee21e242e5d49819ea Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 15 Jul 2025 08:01:05 +0000 Subject: [PATCH 13/20] ui: fix connecting to group with member review (#6072) --- apps/ios/Shared/Model/SimpleXAPI.swift | 13 +++++++++---- apps/ios/Shared/Views/Chat/ChatView.swift | 7 +++++++ .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 1 + 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f184e048cc..adda443a3b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2481,10 +2481,15 @@ func processReceivedMsg(_ res: ChatEvent) async { await MainActor.run { m.updateGroup(groupInfo) } - if m.chatId == groupInfo.id, - case .memberSupport(nil) = m.secondaryIM?.groupScopeInfo { - await MainActor.run { - m.secondaryPendingInviteeChatOpened = false + if m.chatId == groupInfo.id { + if groupInfo.membership.memberPending { + await MainActor.run { + m.secondaryPendingInviteeChatOpened = true + } + } else if case .memberSupport(nil) = m.secondaryIM?.groupScopeInfo { + await MainActor.run { + m.secondaryPendingInviteeChatOpened = false + } } } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index ed0c0aa432..30eb6b7fee 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -290,6 +290,13 @@ struct ChatView: View { } } } + .onChange(of: chatModel.secondaryPendingInviteeChatOpened) { secondaryChatOpened in + if secondaryChatOpened { + ItemsModel.loadSecondaryChat(chat.id, chatFilter: .groupChatScopeContext(groupScopeInfo: userSupportScopeInfo)) { + showUserSupportChatSheet = true + } + } + } .onChange(of: chatModel.chatId) { cId in ConnectProgressManager.shared.cancelConnectProgress() showChatInfoSheet = false 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 769efecad8..ee41e9cbda 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 @@ -2872,6 +2872,7 @@ object ChatController { } if ( chatModel.chatId.value == r.groupInfo.id + && !r.groupInfo.membership.memberPending && ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT) && chatModel.secondaryChatsContext.value?.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext ) { From a94c3d9f39133f06f478516532e1c5435221e185 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 15 Jul 2025 09:15:47 +0000 Subject: [PATCH 14/20] core: don't create duplicate feature items when being accepted to prepared group with member review (#6074) --- src/Simplex/Chat/Library/Subscriber.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 4819f254dd..6a6f406a75 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2389,7 +2389,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let gInfo' = gInfo {membership = membership'} cd = CDGroupRcv gInfo' Nothing m createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing - createGroupFeatureItems user cd CIRcvGroupFeature gInfo' + let prepared = preparedGroup gInfo' + unless (isJust prepared) $ createGroupFeatureItems user cd CIRcvGroupFeature gInfo' let welcomeMsgId_ = (\PreparedGroup {welcomeSharedMsgId = mId} -> mId) <$> preparedGroup gInfo' unless (isJust welcomeMsgId_) $ maybeCreateGroupDescrLocal gInfo' m createInternalChatItem user cd (CIRcvGroupEvent RGEUserAccepted) Nothing From 219d1734b373744978bfe7edb739e5457164c585 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 15 Jul 2025 10:21:37 +0100 Subject: [PATCH 15/20] ui: fix drafts for prepared chats (#6070) * ios: fix placeholder for prepared chats * android, desktop: fix drafts for prepared chats * take effects outside of SndMsgView_ * revert KeyChangeEffect --- .../ComposeMessage/NativeTextEditor.swift | 3 +- .../simplex/common/views/chat/ComposeView.kt | 149 +++++++++--------- .../common/views/chatlist/ChatPreviewView.kt | 120 +++++++------- 3 files changed, 144 insertions(+), 128 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 0dd26c630d..31d4ceecc6 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -63,9 +63,10 @@ struct NativeTextEditor: UIViewRepresentable { field.textAlignment = alignment(text) field.updateFont() field.updateHeight(updateBindingNow: false) + field.placeholder = text.isEmpty ? placeholder : "" } if field.placeholder != placeholder { - field.placeholder = placeholder + field.placeholder = text.isEmpty ? placeholder : "" } if field.selectedRange != selectedRange { field.selectedRange = selectedRange diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index f4ae0d7607..7fc1e9ae56 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -1101,86 +1101,87 @@ fun ComposeView( } } + val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } + LaunchedEffect(allowedVoiceByPrefs) { + if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { + // Voice was disabled right when this user records it, just cancel it + cancelVoice() + } + } + val needToAllowVoiceToContact = remember(chat.chatInfo) { + chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { + ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && + contactPreference.allow == FeatureAllowed.YES + } + } + LaunchedEffect(Unit) { + snapshotFlow { recState.value } + .distinctUntilChanged() + .collect { + when (it) { + is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) + is RecordingState.Finished -> if (it.durationMs > 300) { + onAudioAdded(it.filePath, it.durationMs, true) + } else { + cancelVoice() + } + is RecordingState.NotStarted -> {} + } + } + } + + LaunchedEffect(rememberUpdatedState(chat.chatInfo.sendMsgEnabled).value) { + if (!chat.chatInfo.sendMsgEnabled) { + clearCurrentDraft() + clearState() + } + } + + KeyChangeEffect(chatModel.chatId.value) { prevChatId -> + val cs = composeState.value + if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) { + sendMessage(null) + resetLinkPreview() + clearPrevDraft(prevChatId) + deleteUnusedFiles() + } else if (cs.inProgress) { + clearPrevDraft(prevChatId) + } else if (!cs.empty) { + if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { + composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) + } + if (saveLastDraft) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = prevChatId + } + composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) + } else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) { + composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) + } else { + clearPrevDraft(prevChatId) + deleteUnusedFiles() + } + chatModel.removeLiveDummy() + CIFile.cachedRemoteFileRequests.clear() + } + if (appPlatform.isDesktop) { + // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` + DisposableEffect(Unit) { + onDispose { + if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = chat.id + } + } + } + } + @Composable fun SendMsgView_( disableSendButton: Boolean, placeholder: String? = null, sendToConnect: (() -> Unit)? = null ) { - val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } - LaunchedEffect(allowedVoiceByPrefs) { - if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { - // Voice was disabled right when this user records it, just cancel it - cancelVoice() - } - } - val needToAllowVoiceToContact = remember(chat.chatInfo) { - chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { - ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && - contactPreference.allow == FeatureAllowed.YES - } - } - LaunchedEffect(Unit) { - snapshotFlow { recState.value } - .distinctUntilChanged() - .collect { - when (it) { - is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) - is RecordingState.Finished -> if (it.durationMs > 300) { - onAudioAdded(it.filePath, it.durationMs, true) - } else { - cancelVoice() - } - is RecordingState.NotStarted -> {} - } - } - } - - LaunchedEffect(rememberUpdatedState(chat.chatInfo.sendMsgEnabled).value) { - if (!chat.chatInfo.sendMsgEnabled) { - clearCurrentDraft() - clearState() - } - } - - KeyChangeEffect(chatModel.chatId.value) { prevChatId -> - val cs = composeState.value - if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) { - sendMessage(null) - resetLinkPreview() - clearPrevDraft(prevChatId) - deleteUnusedFiles() - } else if (cs.inProgress) { - clearPrevDraft(prevChatId) - } else if (!cs.empty) { - if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { - composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) - } - if (saveLastDraft) { - chatModel.draft.value = composeState.value - chatModel.draftChatId.value = prevChatId - } - composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) - } else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) { - composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) - } else { - clearPrevDraft(prevChatId) - deleteUnusedFiles() - } - chatModel.removeLiveDummy() - CIFile.cachedRemoteFileRequests.clear() - } - if (appPlatform.isDesktop) { - // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` - DisposableEffect(Unit) { - onDispose { - if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { - chatModel.draft.value = composeState.value - chatModel.draftChatId.value = chat.id - } - } - } - } val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } val sendButtonColor = if (chat.chatInfo.incognito) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index dc208ae099..fdf149e065 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -212,61 +212,75 @@ fun ChatPreviewView( fun chatPreviewText() { val previewText = chatPreviewInfoText() val ci = chat.chatItems.lastOrNull() - if (ci?.content?.hasMsgContent != true && previewText != null) { + if (chatModelDraftChatId == chat.id && chatModelDraft != null) { + val sp20 = with(LocalDensity.current) { 20.sp.toDp() } + val (text: CharSequence, inlineTextContent) = remember(chatModelDraft) { chatModelDraft.message.text to messageDraft(chatModelDraft, sp20) } + val formattedText = null + MarkdownText( + text, + formattedText, + toggleSecrets = false, + linkMode = linkMode, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = TextStyle( + fontFamily = Inter, + fontSize = 15.sp, + color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, + lineHeight = 21.sp + ), + inlineContent = inlineTextContent, + modifier = Modifier.fillMaxWidth() + ) + } else if (ci?.content?.hasMsgContent != true && previewText != null) { Text(previewText.first, color = previewText.second) - } else if (ci != null) { - if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) { - val sp20 = with(LocalDensity.current) { 20.sp.toDp() } - val (text: CharSequence, inlineTextContent) = when { - chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message.text to messageDraft(chatModelDraft, sp20) } - ci.meta.itemDeleted == null -> ci.text to null - else -> markedDeletedText(ci, chat.chatInfo) to null - } - val formattedText = when { - chatModelDraftChatId == chat.id && chatModelDraft != null -> null - ci.meta.itemDeleted == null -> ci.formattedText - else -> null - } - val prefix = when (val mc = ci.content.msgContent) { - is MsgContent.MCReport -> - buildAnnotatedString { - withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { - append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") - } - } - - else -> null - } - - MarkdownText( - text, - formattedText, - sender = when { - chatModelDraftChatId == chat.id && chatModelDraft != null -> null - cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName - else -> null - }, - mentions = ci.mentions, - userMemberId = when { - cInfo is ChatInfo.Group -> cInfo.groupInfo.membership.memberId - else -> null - }, - toggleSecrets = false, - linkMode = linkMode, - senderBold = true, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = TextStyle( - fontFamily = Inter, - fontSize = 15.sp, - color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, - lineHeight = 21.sp - ), - inlineContent = inlineTextContent, - modifier = Modifier.fillMaxWidth(), - prefix = prefix - ) + } else if (ci != null && showChatPreviews) { + val (text: CharSequence, inlineTextContent) = when { + ci.meta.itemDeleted == null -> ci.text to null + else -> markedDeletedText(ci, chat.chatInfo) to null } + val formattedText = when { + ci.meta.itemDeleted == null -> ci.formattedText + else -> null + } + val prefix = when (val mc = ci.content.msgContent) { + is MsgContent.MCReport -> + buildAnnotatedString { + withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { + append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") + } + } + + else -> null + } + + MarkdownText( + text, + formattedText, + sender = when { + cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName + else -> null + }, + mentions = ci.mentions, + userMemberId = when { + cInfo is ChatInfo.Group -> cInfo.groupInfo.membership.memberId + else -> null + }, + toggleSecrets = false, + linkMode = linkMode, + senderBold = true, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = TextStyle( + fontFamily = Inter, + fontSize = 15.sp, + color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, + lineHeight = 21.sp + ), + inlineContent = inlineTextContent, + modifier = Modifier.fillMaxWidth(), + prefix = prefix + ) } } From a6a5afb58e90c57ae73d1547b97f5bb80ae2485b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 15 Jul 2025 13:23:30 +0100 Subject: [PATCH 16/20] ui: use conventional save icon for all files (#6077) --- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../chat/simplex/common/views/chat/item/CIFileView.android.kt | 2 +- .../chat/simplex/common/views/chat/item/ChatItemView.android.kt | 2 +- .../chat/simplex/common/views/chat/item/ChatItemView.desktop.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 30eb6b7fee..1aca0ab5fc 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -2032,7 +2032,7 @@ struct ChatView: View { } label: { Label( NSLocalizedString("Save", comment: "chat item action"), - systemImage: file.cryptoArgs == nil ? "square.and.arrow.down" : "lock.open" + systemImage: "square.and.arrow.down" ) } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt index b24150ed24..e81827cb9a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt @@ -46,7 +46,7 @@ actual fun SaveOrOpenFileMenu( } ItemAction( stringResource(MR.strings.save_verb), - painterResource(if (encrypted) MR.images.ic_lock_open_right else MR.images.ic_download), + painterResource(MR.images.ic_download), color = MaterialTheme.colors.primary, onClick = { saveFile() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt index 9e8eb8ee8f..b0bc6dd970 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt @@ -28,7 +28,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) - ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = { + ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = { when (cItem.content.msgContent) { is MsgContent.MCImage -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.status == PermissionStatus.Granted) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index cd206c8e4e..e6f782d816 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -32,7 +32,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { - ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = { + ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = { val saveIfExists = { when (cItem.content.msgContent) { is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withLongRunningApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } From 1d538fa77287116dafa3155af080a7f649b042eb Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 15 Jul 2025 13:25:52 +0100 Subject: [PATCH 17/20] ios: save connection alias when the sheet is closed (#6078) --- apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift index b9f5b984e1..124c5ee7ba 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift @@ -114,6 +114,7 @@ struct ContactConnectionInfo: View { .onAppear { localAlias = contactConnection.localAlias } + .onDisappear(perform: setConnectionAlias) } private func setConnectionAlias() { From 88f36382ed4b6714d025f6b83021b59081f0b0a8 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 15 Jul 2025 14:02:51 +0100 Subject: [PATCH 18/20] ios: prevent additional QR code scan while opening chat (#6076) --- .../Shared/Views/NewChat/NewChatView.swift | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 499287016a..432422d77b 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -998,7 +998,7 @@ private func showOwnGroupLinkConfirmConnectSheet( title: NSLocalizedString("Open group", comment: "new chat action"), style: .default, handler: { _ in - openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) + openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) } ), UIAlertAction( @@ -1052,8 +1052,7 @@ private func showPrepareContactAlert( let chat = try await apiPrepareContact(connLink: connectionLink, contactShortLinkData: contactShortLinkData) await MainActor.run { ChatModel.shared.addChat(Chat(chat)) - openKnownChat(chat.id, dismiss: dismiss, showAlreadyExistsAlert: nil) - cleanup?() + openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup) } } catch let error { logger.error("showPrepareContactAlert apiPrepareContact error: \(error.localizedDescription)") @@ -1088,8 +1087,7 @@ private func showPrepareGroupAlert( let chat = try await apiPrepareGroup(connLink: connectionLink, groupShortLinkData: groupShortLinkData) await MainActor.run { ChatModel.shared.addChat(Chat(chat)) - openKnownChat(chat.id, dismiss: dismiss, showAlreadyExistsAlert: nil) - cleanup?() + openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup) } } catch let error { logger.error("showPrepareGroupAlert apiPrepareGroup error: \(error.localizedDescription)") @@ -1124,7 +1122,7 @@ private func showOpenKnownContactAlert( ? NSLocalizedString("Open new chat", comment: "new chat action") : NSLocalizedString("Open chat", comment: "new chat action"), onConfirm: { - openKnownContact(contact, dismiss: dismiss, showAlreadyExistsAlert: nil) + openKnownContact(contact, dismiss: dismiss, cleanup: nil) } ) } @@ -1156,7 +1154,7 @@ private func showOpenKnownGroupAlert( : NSLocalizedString("Open chat", comment: "new chat action") ), onConfirm: { - openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) + openKnownGroup(groupInfo, dismiss: dismiss, cleanup: nil) } ) } @@ -1471,28 +1469,28 @@ private func connectViaLink( } } -func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { +func openKnownContact(_ contact: Contact, dismiss: Bool, cleanup: (() -> Void)?) { if let c = ChatModel.shared.getContactChat(contact.contactId) { - openKnownChat(c.id, dismiss: dismiss, showAlreadyExistsAlert: showAlreadyExistsAlert) + openKnownChat(c.id, dismiss: dismiss, cleanup: cleanup) } } -func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { +func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, cleanup: (() -> Void)?) { if let g = ChatModel.shared.getGroupChat(groupInfo.groupId) { - openKnownChat(g.id, dismiss: dismiss, showAlreadyExistsAlert: showAlreadyExistsAlert) + openKnownChat(g.id, dismiss: dismiss, cleanup: cleanup) } } -func openKnownChat(_ chatId: ChatId, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { +func openKnownChat(_ chatId: ChatId, dismiss: Bool, cleanup: (() -> Void)?) { if dismiss { dismissAllSheets(animated: true) { ItemsModel.shared.loadOpenChat(chatId) { - showAlreadyExistsAlert?() + cleanup?() } } } else { ItemsModel.shared.loadOpenChat(chatId) { - showAlreadyExistsAlert?() + cleanup?() } } } From a0ea2ede9e73e2b8b3b6ecc2d82b26e676c7ecbf Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 15 Jul 2025 14:00:56 +0000 Subject: [PATCH 19/20] core: safely read preview chat item to avoid missing chat preview (#6080) --- src/Simplex/Chat/Store/Messages.hs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 1b74255c41..1ba53ec6d6 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -809,8 +809,11 @@ findDirectChatPreviews_ db User {userId} pagination clq = getDirectChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat getDirectChatPreview_ db vr user (DirectChatPD _ contactId lastItemId_ stats) = do contact <- getContact db vr user contactId + ts <- liftIO getCurrentTime lastItem <- case lastItemId_ of - Just lastItemId -> (: []) <$> getDirectChatItem db user contactId lastItemId + Just lastItemId -> do + previewItem <- liftIO $ safeGetDirectItem db user contact ts lastItemId + pure [previewItem] Nothing -> pure [] pure $ AChat SCTDirect (Chat (DirectChat contact) lastItem stats) @@ -917,8 +920,11 @@ findGroupChatPreviews_ db User {userId} pagination clq = getGroupChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do groupInfo <- getGroupInfo db vr user groupId + ts <- liftIO getCurrentTime lastItem <- case lastItemId_ of - Just lastItemId -> (: []) <$> getGroupCIWithReactions db user groupInfo lastItemId + Just lastItemId -> do + previewItem <- liftIO $ safeGetGroupItem db user groupInfo ts lastItemId + pure [previewItem] Nothing -> pure [] pure $ AChat SCTGroup (Chat (GroupChat groupInfo Nothing) lastItem stats) @@ -999,8 +1005,11 @@ findLocalChatPreviews_ db User {userId} pagination clq = getLocalChatPreview_ :: DB.Connection -> User -> ChatPreviewData 'CTLocal -> ExceptT StoreError IO AChat getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do nf <- getNoteFolder db user noteFolderId + ts <- liftIO getCurrentTime lastItem <- case lastItemId_ of - Just lastItemId -> (: []) <$> getLocalChatItem db user noteFolderId lastItemId + Just lastItemId -> do + previewItem <- liftIO $ safeGetLocalItem db user nf ts lastItemId + pure [previewItem] Nothing -> pure [] pure $ AChat SCTLocal (Chat (LocalChat nf) lastItem stats) From 428a72757d0564bd81f46d8c9d2927a93c5171e6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 15 Jul 2025 15:39:27 +0100 Subject: [PATCH 20/20] core: 6.4.0.7 --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 63e9208699..547ded17c0 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.0.6 +version: 6.4.0.7 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat