From e2ecff7215971feffdc262aac826bb8fa21068f2 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:37:37 +0000 Subject: [PATCH 1/9] simplex-chat-nodejs: add member contact API methods (#6763) * simplex-chat-nodejs: add apiCreateMemberContact and apiSendMemberContactInvitation * simplex-chat-nodejs: add integration test for apiCreateMemberContact and apiSendMemberContactInvitation Test creates a 3-user group with direct messages enabled, then verifies: - apiCreateMemberContact creates a DM contact between group members - apiSendMemberContactInvitation sends an invitation that the recipient receives * simplex-chat-nodejs: bump @simplex-chat/types to ^0.4.0 --- packages/simplex-chat-nodejs/package.json | 2 +- packages/simplex-chat-nodejs/src/api.ts | 30 +++++++ .../simplex-chat-nodejs/tests/api.test.ts | 85 +++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 498d502edd..657c6e05f2 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.3.0", + "@simplex-chat/types": "^0.4.0", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index f5d2a5168e..8bc56db41c 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -872,4 +872,34 @@ export class ChatApi { const r = await this.sendChatCmd(CC.APISetContactPrefs.cmdString({contactId, preferences})) if (r.type !== "contactPrefsUpdated") throw new ChatCommandError("error setting contact prefs", r) } + + /** + * Create a direct message contact with a group member. + * Returns the created contact. + * Network usage: interactive. + */ + async apiCreateMemberContact(groupId: number, groupMemberId: number): Promise { + const r: any = await this.sendChatCmd(`/_create member contact #${groupId} ${groupMemberId}`) + if (r.type === "newMemberContact") return r.contact + throw new ChatCommandError("error creating member contact", r) + } + + /** + * Send a direct message invitation to a group member contact. + * The contact must have been created with {@link apiCreateMemberContact}. + * Network usage: interactive. + */ + async apiSendMemberContactInvitation(contactId: number, message?: T.MsgContent | string): Promise { + let cmd = `/_invite member contact @${contactId}` + if (message !== undefined) { + if (typeof message === "string") { + cmd += ` text ${message}` + } else { + cmd += ` json ${JSON.stringify(message)}` + } + } + const r: any = await this.sendChatCmd(cmd) + if (r.type === "newMemberContactSentInv") return r.contact + throw new ChatCommandError("error sending member contact invitation", r) + } } diff --git a/packages/simplex-chat-nodejs/tests/api.test.ts b/packages/simplex-chat-nodejs/tests/api.test.ts index 52153ecfed..7bc1a89b86 100644 --- a/packages/simplex-chat-nodejs/tests/api.test.ts +++ b/packages/simplex-chat-nodejs/tests/api.test.ts @@ -64,4 +64,89 @@ describe("API tests (use preset servers)", () => { expect(servers[0] !== servers[1]).toBe(true) expect(eventCount > 0).toBe(true) }, 30000) + + it("should create member contact and send invitation", async () => { + // create 3 users and start chat controllers + const alice = await api.ChatApi.init(alicePath) + const bob = await api.ChatApi.init(bobPath) + const carolPath = path.join(tmpDir, "carol") + const carol = await api.ChatApi.init(carolPath) + const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""}) + await bob.apiCreateActiveUser({displayName: "bob", fullName: ""}) + await carol.apiCreateActiveUser({displayName: "carol", fullName: ""}) + await alice.startChat() + await bob.startChat() + await carol.startChat() + // connect alice <-> bob + const aliceLink1 = await alice.apiCreateLink(aliceUser.userId) + await expect(bob.apiConnectActiveUser(aliceLink1)).resolves.toBe(api.ConnReqType.Invitation) + const [bobContact] = await Promise.all([ + (await alice.wait("contactConnected")).contact, + (await bob.wait("contactConnected")).contact + ]) + // connect alice <-> carol + const aliceLink2 = await alice.apiCreateLink(aliceUser.userId) + await expect(carol.apiConnectActiveUser(aliceLink2)).resolves.toBe(api.ConnReqType.Invitation) + const [carolContact] = await Promise.all([ + (await alice.wait("contactConnected")).contact, + (await carol.wait("contactConnected")).contact + ]) + // create group with direct messages enabled + const group = await alice.apiNewGroup(aliceUser.userId, { + displayName: "test-group", + fullName: "", + groupPreferences: { + directMessages: {enable: T.GroupFeatureEnabled.On}, + }, + }) + const groupId = group.groupId + // add bob to the group + const bobInvP = bob.wait("receivedGroupInvitation", 15000) + await alice.apiAddMember(groupId, bobContact.contactId, T.GroupMemberRole.Member) + const bobInvEvt = await bobInvP + expect(bobInvEvt).toBeDefined() + const aliceBobConnP = alice.wait("connectedToGroupMember", 15000) + const bobAliceConnP = bob.wait("connectedToGroupMember", 15000) + await bob.apiJoinGroup(bobInvEvt!.groupInfo.groupId) + await Promise.all([aliceBobConnP, bobAliceConnP]) + // add carol to the group + const carolInvP = carol.wait("receivedGroupInvitation", 30000) + await alice.apiAddMember(groupId, carolContact.contactId, T.GroupMemberRole.Member) + const carolInvEvt = await carolInvP + expect(carolInvEvt).toBeDefined() + // wait for carol to connect to both alice and bob (and vice versa) + const bobCarolConnP = bob.wait("connectedToGroupMember", + (evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "carol", 30000) + const carolAliceConnP = carol.wait("connectedToGroupMember", + (evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "alice", 30000) + const carolBobConnP = carol.wait("connectedToGroupMember", + (evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "bob", 30000) + const aliceCarolConnP = alice.wait("connectedToGroupMember", + (evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "carol", 30000) + await carol.apiJoinGroup(carolInvEvt!.groupInfo.groupId) + await Promise.all([bobCarolConnP, carolAliceConnP, carolBobConnP, aliceCarolConnP]) + // find carol's memberId from bob's perspective + const members = await bob.apiListMembers(groupId) + const carolMember = members.find(m => m.memberProfile.displayName === "carol") + expect(carolMember).toBeDefined() + // test apiCreateMemberContact + const dmContact = await bob.apiCreateMemberContact(groupId, carolMember!.groupMemberId) + expect(dmContact).toBeDefined() + expect(dmContact.contactId).toBeDefined() + // test apiSendMemberContactInvitation + const carolDmP = carol.wait("newMemberContactReceivedInv" as CEvt.Tag, 30000) + const invContact = await bob.apiSendMemberContactInvitation(dmContact.contactId, "hello from bob") + expect(invContact).toBeDefined() + // carol should receive the member contact invitation + const carolDmEvt = await carolDmP + expect(carolDmEvt).toBeDefined() + expect((carolDmEvt as any).contact).toBeDefined() + // cleanup + await alice.stopChat() + await bob.stopChat() + await carol.stopChat() + await alice.close() + await bob.close() + await carol.close() + }, 90000) }) From 98f194263897d5206ea026f74bb469b2beed3b10 Mon Sep 17 00:00:00 2001 From: SimpleX Chat Date: Fri, 10 Apr 2026 11:26:07 +0000 Subject: [PATCH 2/9] 6.5-beta.8: android 340, desktop 135 --- apps/multiplatform/gradle.properties | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 1926f35f0f..3446ef9c30 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.5-beta.7 -android.version_code=339 +android.version_name=6.5-beta.8 +android.version_code=340 android.bundle=false -desktop.version_name=6.5-beta.7 -desktop.version_code=134 +desktop.version_name=6.5-beta.8 +desktop.version_code=135 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 From 393e11c0c461c0aef548f2411b0b7fee0b64de4d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:15:33 +0100 Subject: [PATCH 3/9] 6.5-beta.8: ios 325 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 36 +++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 32d74395ba..63191e4fb2 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -182,8 +182,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.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.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.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.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 = ""; }; @@ -716,8 +716,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -803,8 +803,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a */, ); path = Libraries; sourceTree = ""; @@ -2019,7 +2019,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 324; + CURRENT_PROJECT_VERSION = 325; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2069,7 +2069,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 324; + CURRENT_PROJECT_VERSION = 325; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2111,7 +2111,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 324; + CURRENT_PROJECT_VERSION = 325; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2131,7 +2131,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 324; + CURRENT_PROJECT_VERSION = 325; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2156,7 +2156,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 324; + CURRENT_PROJECT_VERSION = 325; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2193,7 +2193,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 324; + CURRENT_PROJECT_VERSION = 325; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2230,7 +2230,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 324; + CURRENT_PROJECT_VERSION = 325; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2281,7 +2281,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 324; + CURRENT_PROJECT_VERSION = 325; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2332,7 +2332,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 324; + CURRENT_PROJECT_VERSION = 325; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2366,7 +2366,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 324; + CURRENT_PROJECT_VERSION = 325; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From 75d62b08ca91ae6b7e171f40b8d2dd322fe23f15 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:25:06 +0000 Subject: [PATCH 4/9] ui: group service events channel texts (#6781) --- apps/ios/Shared/Views/Chat/ChatItemView.swift | 12 +- .../Views/ChatList/ChatPreviewView.swift | 2 +- apps/ios/SimpleXChat/ChatTypes.swift | 104 ++++++++++-------- apps/ios/SimpleXChat/Notifications.swift | 6 +- .../chat/simplex/common/model/ChatModel.kt | 35 ++++-- .../simplex/common/platform/NtfManager.kt | 8 +- .../common/views/chat/item/ChatItemView.kt | 12 +- .../common/views/chatlist/ChatPreviewView.kt | 2 +- .../commonMain/resources/MR/base/strings.xml | 4 + 9 files changed, 108 insertions(+), 77 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index f72bf083f6..138aed6c65 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -195,7 +195,7 @@ struct ChatItemContentView: View { } private func pendingReviewEventItemText() -> Text { - Text(chatItem.content.text) + Text(chatItem.content.text(isChannel: chat.chatInfo.isChannel)) .font(.caption) .foregroundColor(theme.colors.secondary) .fontWeight(.bold) @@ -209,9 +209,9 @@ struct ChatItemContentView: View { .font(.caption) .foregroundColor(secondaryColor) .fontWeight(.light) - + chatEventText(chatItem, secondaryColor) + + chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel) } else { - return chatEventText(chatItem, secondaryColor) + return chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel) } } @@ -234,7 +234,7 @@ struct ChatItemContentView: View { return if count <= 1 { nil } else if ns.count == 0 { - Text("\(count) group events") + chat.chatInfo.isChannel ? Text("\(count) channel events") : Text("\(count) group events") } else if count > ns.count { Text(members) + textSpace + Text("and \(count - ns.count) other events") } else { @@ -275,8 +275,8 @@ func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor chatEventText(Text(eventText) + textSpace + ts, secondaryColor) } -func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text { - chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor) +func chatEventText(_ ci: ChatItem, _ secondaryColor: Color, isChannel: Bool = false) -> Text { + chatEventText("\(ci.content.text(isChannel: isChannel))", ci.timestampText, secondaryColor) } struct ChatItemView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 112e4099c0..3524ceff18 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -296,7 +296,7 @@ struct ChatPreviewView: View { } func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) { - let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() + let itemText = cItem.meta.itemDeleted == nil ? cItem.text(isChannel: chat.chatInfo.isChannel) : markedDeletedText() let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) return (Text(AttributedString(r.string)), r.hasSecrets) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index b3d144b446..4a14d3ae99 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1652,6 +1652,10 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + public var isChannel: Bool { + groupInfo?.useRelays == true + } + // this works for features that are common for contacts and groups public func featureEnabled(_ feature: ChatFeature) -> Bool { switch self { @@ -3191,11 +3195,13 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var timestampText: Text { meta.timestampText } - public var text: String { - switch (content.text, content.msgContent, file) { + public var text: String { text(isChannel: false) } + + public func text(isChannel: Bool) -> String { + switch (content.text(isChannel: isChannel), content.msgContent, file) { case let ("", .some(.voice(_, duration)), _): return "Voice message (\(durationText(duration)))" case let ("", _, .some(file)): return file.fileName - default: return content.text + default: return content.text(isChannel: isChannel) } } @@ -4047,42 +4053,42 @@ public enum CIContent: Decodable, ItemContent, Hashable { case chatBanner case invalidJSON(json: Data?) - public var text: String { - get { - switch self { - case let .sndMsgContent(mc): return mc.text - case let .rcvMsgContent(mc): return mc.text - case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") - case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") - case let .sndCall(status, duration): return status.text(duration) - case let .rcvCall(status, duration): return status.text(duration) - case let .rcvIntegrityError(msgError): return msgError.text - case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text - case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text - case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text - case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text - case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text - case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text - case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text - case let .sndConnEvent(sndConnEvent): return sndConnEvent.text - case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) - case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) - case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) - case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) - case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) - case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) - case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text) - case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text) - case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item") - case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item") - case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item") - case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) - case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) - case .sndGroupE2EEInfo: return e2eeInfoNoPQStr - case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr - case .chatBanner: return "" - case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item") - } + public var text: String { text(isChannel: false) } + + public func text(isChannel: Bool) -> String { + switch self { + case let .sndMsgContent(mc): return mc.text + case let .rcvMsgContent(mc): return mc.text + case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case let .sndCall(status, duration): return status.text(duration) + case let .rcvCall(status, duration): return status.text(duration) + case let .rcvIntegrityError(msgError): return msgError.text + case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text + case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text + case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text + case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text + case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text(isChannel: isChannel) + case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text(isChannel: isChannel) + case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text + case let .sndConnEvent(sndConnEvent): return sndConnEvent.text + case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) + case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) + case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) + case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) + case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) + case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) + case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text) + case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text) + case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item") + case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item") + case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item") + case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) + case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) + case .sndGroupE2EEInfo: return e2eeInfoNoPQStr + case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr + case .chatBanner: return "" + case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item") } } @@ -5153,7 +5159,9 @@ public enum RcvGroupEvent: Decodable, Hashable { case memberProfileUpdated(fromProfile: Profile, toProfile: Profile) case newMemberPendingReview - var text: String { + var text: String { text(isChannel: false) } + + func text(isChannel: Bool) -> String { switch self { case let .memberAdded(_, profile): return String.localizedStringWithFormat(NSLocalizedString("invited %@", comment: "rcv group event chat item"), profile.profileViewName) @@ -5175,8 +5183,12 @@ public enum RcvGroupEvent: Decodable, Hashable { case let .memberDeleted(_, profile): return String.localizedStringWithFormat(NSLocalizedString("removed %@", comment: "rcv group event chat item"), profile.profileViewName) case .userDeleted: return NSLocalizedString("removed you", comment: "rcv group event chat item") - case .groupDeleted: return NSLocalizedString("deleted group", comment: "rcv group event chat item") - case .groupUpdated: return NSLocalizedString("updated group profile", comment: "rcv group event chat item") + case .groupDeleted: return isChannel + ? NSLocalizedString("deleted channel", comment: "rcv group event chat item") + : NSLocalizedString("deleted group", comment: "rcv group event chat item") + case .groupUpdated: return isChannel + ? NSLocalizedString("updated channel profile", comment: "rcv group event chat item") + : NSLocalizedString("updated group profile", comment: "rcv group event chat item") case .invitedViaGroupLink: return NSLocalizedString("invited via your group link", comment: "rcv group event chat item") case .memberCreatedContact: return NSLocalizedString("requested connection", comment: "rcv group event chat item") case let .memberProfileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile) @@ -5208,7 +5220,9 @@ public enum SndGroupEvent: Decodable, Hashable { case memberAccepted(groupMemberId: Int64, profile: Profile) case userPendingReview - var text: String { + var text: String { text(isChannel: false) } + + func text(isChannel: Bool) -> String { switch self { case let .memberRole(_, profile, role): return String.localizedStringWithFormat(NSLocalizedString("you changed role of %@ to %@", comment: "snd group event chat item"), profile.profileViewName, role.text) @@ -5223,7 +5237,9 @@ public enum SndGroupEvent: Decodable, Hashable { case let .memberDeleted(_, profile): return String.localizedStringWithFormat(NSLocalizedString("you removed %@", comment: "snd group event chat item"), profile.profileViewName) case .userLeft: return NSLocalizedString("you left", comment: "snd group event chat item") - case .groupUpdated: return NSLocalizedString("group profile updated", comment: "snd group event chat item") + case .groupUpdated: return isChannel + ? NSLocalizedString("channel profile updated", comment: "snd group event chat item") + : NSLocalizedString("group profile updated", comment: "snd group event chat item") case .memberAccepted: return NSLocalizedString("you accepted this member", comment: "snd group event chat item") case .userPendingReview: return NSLocalizedString("Please wait for group moderators to review your request to join the group.", comment: "snd group event chat item") diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 24dc58202a..a40e8eda99 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -74,7 +74,7 @@ public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ return createNotification( categoryIdentifier: ntfCategoryMessageReceived, title: title, - body: previewMode == .message ? hideSecrets(cItem) : NSLocalizedString("new message", comment: "notification"), + body: previewMode == .message ? hideSecrets(cItem, isChannel: cInfo.isChannel) : NSLocalizedString("new message", comment: "notification"), targetContentIdentifier: cInfo.id, userInfo: ["userId": user.userId], // userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] @@ -197,7 +197,7 @@ public func createNotification( } // Spec: spec/services/notifications.md#hideSecrets -func hideSecrets(_ cItem: ChatItem) -> String { +func hideSecrets(_ cItem: ChatItem, isChannel: Bool = false) -> String { if let md = cItem.formattedText { var res = "" for ft in md { @@ -213,7 +213,7 @@ func hideSecrets(_ cItem: ChatItem) -> String { if case let .report(text, reason) = mc { return String.localizedStringWithFormat(NSLocalizedString("Report: %@", comment: "report in notification"), text.isEmpty ? reason.text : text) } else { - return cItem.text + return cItem.text(isChannel: isChannel) } } } 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 0ac7a1b973..4e406044e5 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 @@ -1748,6 +1748,9 @@ sealed class ChatInfo: SomeChat, NamedChat { is Group -> groupInfo else -> null } + + val isChannel: Boolean + get() = groupInfo_?.useRelays == true } @Serializable @@ -2891,12 +2894,14 @@ data class ChatItem ( val id: Long get() = meta.itemId val timestampText: String get() = meta.timestampText - val text: String get() { + val text: String get() = text(isChannel = false) + + fun text(isChannel: Boolean): String { val mc = content.msgContent return when { - content.text == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(MR.strings.voice_message_with_duration), durationText(mc.duration)) - content.text == "" && file != null -> file.fileName - else -> content.text + content.text(isChannel) == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(MR.strings.voice_message_with_duration), durationText(mc.duration)) + content.text(isChannel) == "" && file != null -> file.fileName + else -> content.text(isChannel) } } @@ -3754,7 +3759,9 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("chatBanner") object ChatBanner: CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("invalidJSON") data class InvalidJSON(val json: String): CIContent() { override val msgContent: MsgContent? get() = null } - override val text: String get() = when (this) { + override val text: String get() = text(isChannel = false) + + fun text(isChannel: Boolean): String = when (this) { is SndMsgContent -> msgContent.text is RcvMsgContent -> msgContent.text is SndDeleted -> generalGetString(MR.strings.deleted_description) @@ -3766,8 +3773,8 @@ sealed class CIContent: ItemContent { is RcvGroupInvitation -> groupInvitation.text is SndGroupInvitation -> groupInvitation.text is RcvDirectEventContent -> rcvDirectEvent.text - is RcvGroupEventContent -> rcvGroupEvent.text - is SndGroupEventContent -> sndGroupEvent.text + is RcvGroupEventContent -> rcvGroupEvent.text(isChannel) + is SndGroupEventContent -> sndGroupEvent.text(isChannel) is RcvConnEventContent -> rcvConnEvent.text is SndConnEventContent -> sndConnEvent.text is RcvChatFeature -> featureText(feature, enabled.text, param) @@ -4764,7 +4771,9 @@ sealed class RcvGroupEvent() { @Serializable @SerialName("memberProfileUpdated") class MemberProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvGroupEvent() @Serializable @SerialName("newMemberPendingReview") class NewMemberPendingReview(): RcvGroupEvent() - val text: String get() = when (this) { + val text: String get() = text(isChannel = false) + + fun text(isChannel: Boolean): String = when (this) { is MemberAdded -> String.format(generalGetString(MR.strings.rcv_group_event_member_added), profile.profileViewName) is MemberConnected -> generalGetString(MR.strings.rcv_group_event_member_connected) is MemberAccepted -> String.format(generalGetString(MR.strings.rcv_group_event_member_accepted), profile.profileViewName) @@ -4779,8 +4788,8 @@ sealed class RcvGroupEvent() { is UserRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_your_role), role.text) is MemberDeleted -> String.format(generalGetString(MR.strings.rcv_group_event_member_deleted), profile.profileViewName) is UserDeleted -> generalGetString(MR.strings.rcv_group_event_user_deleted) - is GroupDeleted -> generalGetString(MR.strings.rcv_group_event_group_deleted) - is GroupUpdated -> generalGetString(MR.strings.rcv_group_event_updated_group_profile) + is GroupDeleted -> generalGetString(if (isChannel) MR.strings.rcv_channel_event_channel_deleted else MR.strings.rcv_group_event_group_deleted) + is GroupUpdated -> generalGetString(if (isChannel) MR.strings.rcv_channel_event_updated_channel_profile else MR.strings.rcv_group_event_updated_group_profile) is InvitedViaGroupLink -> generalGetString(MR.strings.rcv_group_event_invited_via_your_group_link) is MemberCreatedContact -> generalGetString(MR.strings.rcv_group_event_member_created_contact) is MemberProfileUpdated -> profileUpdatedText(fromProfile, toProfile) @@ -4812,7 +4821,9 @@ sealed class SndGroupEvent() { @Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): SndGroupEvent() @Serializable @SerialName("userPendingReview") class UserPendingReview(): SndGroupEvent() - val text: String get() = when (this) { + val text: String get() = text(isChannel = false) + + fun text(isChannel: Boolean): String = when (this) { is MemberRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_member_role), profile.profileViewName, role.text) is UserRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_role_for_yourself), role.text) is MemberBlocked -> if (blocked) { @@ -4822,7 +4833,7 @@ sealed class SndGroupEvent() { } is MemberDeleted -> String.format(generalGetString(MR.strings.snd_group_event_member_deleted), profile.profileViewName) is UserLeft -> generalGetString(MR.strings.snd_group_event_user_left) - is GroupUpdated -> generalGetString(MR.strings.snd_group_event_group_profile_updated) + is GroupUpdated -> generalGetString(if (isChannel) MR.strings.snd_channel_event_channel_profile_updated else MR.strings.snd_group_event_group_profile_updated) is MemberAccepted -> generalGetString(MR.strings.snd_group_event_member_accepted) is UserPendingReview -> generalGetString(MR.strings.snd_group_event_user_pending_review) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 39fcea3981..385120f18b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -44,7 +44,7 @@ abstract class NtfManager { chatModel.chatId.value != cInfo.id || chatModel.remoteHostId() != rhId) ) { - displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem)) + displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem, cInfo.isChannel)) } } @@ -119,7 +119,7 @@ abstract class NtfManager { } } - private fun hideSecrets(cItem: ChatItem): String { + private fun hideSecrets(cItem: ChatItem, isChannel: Boolean = false): String { val md = cItem.formattedText return if (md != null) { var res = "" @@ -130,9 +130,9 @@ abstract class NtfManager { } else { val mc = cItem.content.msgContent if (mc is MsgContent.MCReport) { - generalGetString(MR.strings.notification_group_report).format(cItem.text.ifEmpty { mc.reason.text }) + generalGetString(MR.strings.notification_group_report).format(cItem.text(isChannel).ifEmpty { mc.reason.text }) } else { - cItem.text + cItem.text(isChannel) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 05c84db4c3..d2d0a91c9b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -48,8 +48,8 @@ private val msgTailMaxHeightDp = msgTailWidthDp * 1.732f // 60deg val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary) -fun chatEventText(ci: ChatItem): AnnotatedString = - chatEventText(ci.content.text, ci.timestampText) +fun chatEventText(ci: ChatItem, isChannel: Boolean = false): AnnotatedString = + chatEventText(ci.content.text(isChannel), ci.timestampText) fun chatEventText(eventText: String, ts: String): AnnotatedString = buildAnnotatedString { @@ -612,7 +612,7 @@ fun ChatItemView( return if (count <= 1) { null } else if (ns.isEmpty()) { - generalGetString(MR.strings.rcv_group_events_count).format(count) + generalGetString(if (cInfo.isChannel) MR.strings.rcv_channel_events_count else MR.strings.rcv_group_events_count).format(count) } else if (count > ns.size) { members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size) } else { @@ -629,9 +629,9 @@ fun ChatItemView( buildAnnotatedString { withStyle(chatEventStyle) { append(memberDisplayName) } append(" ") - }.plus(chatEventText(cItem)) + }.plus(chatEventText(cItem, cInfo.isChannel)) } else { - chatEventText(cItem) + chatEventText(cItem, cInfo.isChannel) } } @@ -643,7 +643,7 @@ fun ChatItemView( @Composable fun PendingReviewEventItemView() { Text( buildAnnotatedString { - withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text) } + withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text(cInfo.isChannel)) } }, Modifier.padding(horizontal = 6.dp, vertical = 6.dp) ) 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 9248ac6efe..f5e0389043 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 @@ -241,7 +241,7 @@ fun ChatPreviewView( Text(previewText.first, color = previewText.second) } else if (ci != null && showChatPreviews) { val (text: CharSequence, inlineTextContent) = when { - ci.meta.itemDeleted == null -> ci.text to null + ci.meta.itemDeleted == null -> ci.text(chat.chatInfo.isChannel) to null else -> markedDeletedText(ci, chat.chatInfo) to null } val formattedText = when { 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 f2872f3f0a..609b6c4b3e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1707,7 +1707,9 @@ removed %1$s removed you deleted group + deleted channel updated group profile + updated channel profile invited via your group link requested connection New member wants to join the group. @@ -1718,6 +1720,7 @@ you removed %1$s you left group profile updated + channel profile updated you accepted this member Please wait for group moderators to review your request to join the group. @@ -1726,6 +1729,7 @@ %s, %s and %s connected %s, %s and %d other members connected %d group events + %d channel events and %d other events %s and %s %s, %s and %d members From 9f1ff78d1a31ed6aa17d11620b10af5727514355 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:55:20 +0000 Subject: [PATCH 5/9] desktop: fix nanohttpd jitpack dependency (#6784) Jitpack stopped serving nanohttpd under the 10-char commit hash efb2ebf85a (returns 404), while the 7-char short hash efb2ebf resolves to the same commit and jars. Switch to the short form to unbreak desktopCompileClasspath. --- apps/multiplatform/common/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 602b8f2b90..ea5579ed7d 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -118,8 +118,8 @@ kotlin { implementation("org.slf4j:slf4j-simple:2.0.12") implementation("uk.co.caprica:vlcj:4.8.3") implementation("net.java.dev.jna:jna:5.14.0") - implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a") - implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a") + implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf") + implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf") implementation("com.squareup.okhttp3:okhttp:4.12.0") } } From 1428ad75f9686c7aabb5e4052d16c5f93bb1f731 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:05:51 +0000 Subject: [PATCH 6/9] core: channel comments preference (#6780) * core: channel comments preference * api * remove from all group prefs * rename * fix api docs/types --------- Co-authored-by: Evgeny Poberezkin --- bots/api/TYPES.md | 13 +++++ bots/src/API/Docs/Types.hs | 2 + .../types/typescript/src/types.ts | 8 +++ src/Simplex/Chat/Types/Preferences.hs | 50 +++++++++++++++++-- tests/ProtocolTests.hs | 2 +- 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index c2303e9e6d..7a68fa92a9 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -51,6 +51,7 @@ This file is generated automatically. - [Color](#color) - [CommandError](#commanderror) - [CommandErrorType](#commanderrortype) +- [CommentsGroupPreference](#commentsgrouppreference) - [ComposedMessage](#composedmessage) - [ConnStatus](#connstatus) - [ConnType](#conntype) @@ -1480,6 +1481,15 @@ LARGE: - type: "LARGE" +--- + +## CommentsGroupPreference + +**Record type**: +- enable: [GroupFeatureEnabled](#groupfeatureenabled) +- duration: int? + + --- ## ComposedMessage @@ -2070,6 +2080,7 @@ Phone: - reports: [GroupPreference](#grouppreference) - history: [GroupPreference](#grouppreference) - sessions: [RoleGroupPreference](#rolegrouppreference) +- comments: [CommentsGroupPreference](#commentsgrouppreference) - commands: [[ChatBotCommand](#chatbotcommand)] @@ -2160,6 +2171,7 @@ MemberSupport: - "reports" - "history" - "sessions" +- "comments" --- @@ -2376,6 +2388,7 @@ Known: - reports: [GroupPreference](#grouppreference)? - history: [GroupPreference](#grouppreference)? - sessions: [RoleGroupPreference](#rolegrouppreference)? +- comments: [CommentsGroupPreference](#commentsgrouppreference)? - commands: [[ChatBotCommand](#chatbotcommand)]? diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 37fc6121ce..e55e037243 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -237,6 +237,7 @@ chatTypesDocsData = (sti @Color, STEnum, "", [], "", ""), (sti @CommandError, STUnion, "", [], "", ""), (sti @CommandErrorType, STUnion, "", [], "", ""), + (sti @CommentsGroupPreference, STRecord, "", [], "", ""), (sti @ComposedMessage, STRecord, "", [], "", ""), (sti @Connection, STRecord, "", [], "", ""), (sti @ConnectionEntity, STUnion, "", [], "", ""), @@ -435,6 +436,7 @@ deriving instance Generic ClientNotice deriving instance Generic Color deriving instance Generic CommandError deriving instance Generic CommandErrorType +deriving instance Generic CommentsGroupPreference deriving instance Generic ComposedMessage deriving instance Generic Connection deriving instance Generic ConnectionEntity diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 34b34ccbd5..ef232a6461 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -1714,6 +1714,11 @@ export namespace CommandErrorType { } } +export interface CommentsGroupPreference { + enable: GroupFeatureEnabled + duration?: number // int +} + export interface ComposedMessage { fileSource?: CryptoFile quotedItemId?: number // int64 @@ -2420,6 +2425,7 @@ export interface FullGroupPreferences { reports: GroupPreference history: GroupPreference sessions: RoleGroupPreference + comments: CommentsGroupPreference commands: ChatBotCommand[] } @@ -2492,6 +2498,7 @@ export enum GroupFeature { Reports = "reports", History = "history", Sessions = "sessions", + Comments = "comments", } export enum GroupFeatureEnabled { @@ -2669,6 +2676,7 @@ export interface GroupPreferences { reports?: GroupPreference history?: GroupPreference sessions?: RoleGroupPreference + comments?: CommentsGroupPreference commands?: ChatBotCommand[] } diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index d8c6f10b3a..c02d2b8433 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -177,6 +177,7 @@ data GroupFeature | GFReports | GFHistory | GFSessions + | GFComments deriving (Show) data SGroupFeature (f :: GroupFeature) where @@ -190,6 +191,7 @@ data SGroupFeature (f :: GroupFeature) where SGFReports :: SGroupFeature 'GFReports SGFHistory :: SGroupFeature 'GFHistory SGFSessions :: SGroupFeature 'GFSessions + SGFComments :: SGroupFeature 'GFComments deriving instance Show (SGroupFeature f) @@ -217,6 +219,7 @@ groupFeatureNameText = \case GFReports -> "Member reports" GFHistory -> "Recent history" GFSessions -> "Chat sessions" + GFComments -> "Comments" groupFeatureNameText' :: SGroupFeature f -> Text groupFeatureNameText' = groupFeatureNameText . toGroupFeature @@ -230,6 +233,11 @@ groupFeatureMemberAllowed' feature role prefs = let pref = getGroupPreference feature prefs in getField @"enable" pref == FEOn && maybe True (role >=) (getField @"role" pref) +-- TODO: some preferences are channel-only (e.g., comments) and should not generate +-- UI items or be configurable in regular groups. Currently they are simply excluded +-- from this list. When more channel-only or group-only preferences are added, +-- consider adding a scope property to GroupFeatureI (e.g., GFScopeAll | GFScopeChannel | GFScopeGroup) +-- and filtering at the call sites in createGroupFeatureItems_ / createGroupFeatureChangedItems. allGroupFeatures :: [AGroupFeature] allGroupFeatures = [ AGF SGFTimedMessages, @@ -244,7 +252,7 @@ allGroupFeatures = ] groupPrefSel :: SGroupFeature f -> GroupPreferences -> Maybe (GroupFeaturePreference f) -groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, sessions} = case f of +groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, sessions, comments} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -255,6 +263,7 @@ groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reac SGFReports -> reports SGFHistory -> history SGFSessions -> sessions + SGFComments -> comments toGroupFeature :: SGroupFeature f -> GroupFeature toGroupFeature = \case @@ -268,6 +277,7 @@ toGroupFeature = \case SGFReports -> GFReports SGFHistory -> GFHistory SGFSessions -> GFSessions + SGFComments -> GFComments class GroupPreferenceI p where getGroupPreference :: SGroupFeature f -> p -> GroupFeaturePreference f @@ -279,7 +289,7 @@ instance GroupPreferenceI (Maybe GroupPreferences) where getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt =<< prefs) instance GroupPreferenceI FullGroupPreferences where - getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, sessions} = case f of + getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, sessions, comments} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -290,6 +300,7 @@ instance GroupPreferenceI FullGroupPreferences where SGFReports -> reports SGFHistory -> history SGFSessions -> sessions + SGFComments -> comments {-# INLINE getGroupPreference #-} -- collection of optional group preferences @@ -304,6 +315,7 @@ data GroupPreferences = GroupPreferences reports :: Maybe ReportsGroupPreference, history :: Maybe HistoryGroupPreference, sessions :: Maybe SessionsGroupPreference, + comments :: Maybe CommentsGroupPreference, commands :: Maybe [ChatBotCommand] } deriving (Eq, Show) @@ -354,6 +366,7 @@ setGroupPreference_ f pref prefs = SGFReports -> prefs {reports = pref} SGFHistory -> prefs {history = pref} SGFSessions -> prefs {sessions = pref} + SGFComments -> prefs {comments = pref} setGroupTimedMessagesPreference :: TimedMessagesGroupPreference -> Maybe GroupPreferences -> GroupPreferences setGroupTimedMessagesPreference pref prefs_ = @@ -396,6 +409,7 @@ data FullGroupPreferences = FullGroupPreferences reports :: ReportsGroupPreference, history :: HistoryGroupPreference, sessions :: SessionsGroupPreference, + comments :: CommentsGroupPreference, commands :: ListDef ChatBotCommand } deriving (Eq, Show) @@ -465,11 +479,12 @@ defaultGroupPrefs = reports = ReportsGroupPreference {enable = FEOn}, history = HistoryGroupPreference {enable = FEOff}, sessions = SessionsGroupPreference {enable = FEOff, role = Nothing}, + comments = CommentsGroupPreference {enable = FEOff, duration = Nothing}, commands = ListDef [] } emptyGroupPrefs :: GroupPreferences -emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing +emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing businessGroupPrefs :: Preferences -> GroupPreferences businessGroupPrefs Preferences {timedMessages, fullDelete, reactions, voice, files, sessions, commands} = @@ -501,6 +516,7 @@ defaultBusinessGroupPrefs = reports = Just $ ReportsGroupPreference FEOff, history = Just $ HistoryGroupPreference FEOn, sessions = Just $ SessionsGroupPreference FEOn Nothing, + comments = Just $ CommentsGroupPreference FEOff Nothing, commands = Nothing } @@ -635,6 +651,14 @@ data SessionsGroupPreference = SessionsGroupPreference {enable :: GroupFeatureEnabled, role :: Maybe GroupMemberRole} deriving (Eq, Show) +-- Channel comments. ``duration` is time in seconds since post creation +-- after which a channel post stops accepting new comments; `Nothing` means accept comments indefinitely. +data CommentsGroupPreference = CommentsGroupPreference + { enable :: GroupFeatureEnabled, + duration :: Maybe Int + } + deriving (Eq, Show) + class (Eq (GroupFeaturePreference f), HasField "enable" (GroupFeaturePreference f) GroupFeatureEnabled) => GroupFeatureI f where type GroupFeaturePreference (f :: GroupFeature) = p | p -> f sGroupFeature :: SGroupFeature f @@ -678,6 +702,9 @@ instance HasField "enable" HistoryGroupPreference GroupFeatureEnabled where instance HasField "enable" SessionsGroupPreference GroupFeatureEnabled where hasField p@SessionsGroupPreference {enable} = (\e -> p {enable = e}, enable) +instance HasField "enable" CommentsGroupPreference GroupFeatureEnabled where + hasField p@CommentsGroupPreference {enable} = (\e -> p {enable = e}, enable) + instance GroupFeatureI 'GFTimedMessages where type GroupFeaturePreference 'GFTimedMessages = TimedMessagesGroupPreference sGroupFeature = SGFTimedMessages @@ -738,6 +765,12 @@ instance GroupFeatureI 'GFSessions where groupPrefParam _ = Nothing groupPrefRole SessionsGroupPreference {role} = role +instance GroupFeatureI 'GFComments where + type GroupFeaturePreference 'GFComments = CommentsGroupPreference + sGroupFeature = SGFComments + groupPrefParam CommentsGroupPreference {duration} = duration + groupPrefRole _ = Nothing + instance GroupFeatureNoRoleI 'GFTimedMessages instance GroupFeatureNoRoleI 'GFFullDelete @@ -748,6 +781,8 @@ instance GroupFeatureNoRoleI 'GFReports instance GroupFeatureNoRoleI 'GFHistory +instance GroupFeatureNoRoleI 'GFComments + instance HasField "role" DirectMessagesGroupPreference (Maybe GroupMemberRole) where hasField p@DirectMessagesGroupPreference {role} = (\r -> p {role = r}, role) @@ -788,6 +823,7 @@ groupPrefStateText feature pref param role = groupParamText_ :: GroupFeature -> Maybe Int -> Text groupParamText_ feature param = case feature of GFTimedMessages -> maybe "" (\p -> " (" <> timedTTLText p <> ")") param + GFComments -> maybe "" (\p -> " (close after " <> timedTTLText p <> ")") param _ -> "" groupPreferenceText :: forall f. GroupFeatureI f => GroupFeaturePreference f -> Text @@ -938,6 +974,7 @@ mergeGroupPreferences groupPreferences = reports = pref SGFReports, history = pref SGFHistory, sessions = pref SGFSessions, + comments = pref SGFComments, commands = ListDef $ fromMaybe [] $ groupPreferences >>= commands_ } where @@ -957,6 +994,7 @@ toGroupPreferences groupPreferences@FullGroupPreferences {commands = ListDef cmd reports = pref SGFReports, history = pref SGFHistory, sessions = pref SGFSessions, + comments = pref SGFComments, commands = Just cmds } where @@ -1091,6 +1129,12 @@ instance FromJSON SessionsGroupPreference where parseJSON v = $(J.mkParseJSON defaultJSON ''SessionsGroupPreference) v omittedField = Just SessionsGroupPreference {enable = FEOff, role = Nothing} +$(J.deriveToJSON defaultJSON ''CommentsGroupPreference) + +instance FromJSON CommentsGroupPreference where + parseJSON v = $(J.mkParseJSON defaultJSON ''CommentsGroupPreference) v + omittedField = Just CommentsGroupPreference {enable = FEOff, duration = Nothing} + $(J.deriveJSON defaultJSON ''GroupPreferences) instance ToField GroupPreferences where diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 1b708a2ffa..6d9ee54e6c 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -101,7 +101,7 @@ testChatPreferences :: Maybe Preferences testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, files = Nothing, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}, sessions = Nothing, commands = Nothing} testGroupPreferences :: Maybe GroupPreferences -testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, sessions = Nothing, commands = Nothing} +testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, sessions = Nothing, comments = Nothing, commands = Nothing} testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences} From 6f21826579284b00f3b6ebc59caf5a56bb3ecf3c Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 11 Apr 2026 19:40:33 +0100 Subject: [PATCH 7/9] core, ui: chat item to show message error (#6785) * core: chat item to show message error * ui: chat item for removed messages * remove local maven repo * command to test dropped messages * update nix config * show parse errors * error texts, simplexmq * alert messages * simplexmq, alert * better parsing * better parsing * simplify * correct message * remove test api * do not check size twice, bot types * send error in relays * do not create error item in relays * diff --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../ChatItem/IntegrityErrorItemView.swift | 18 ++++++++++ apps/ios/Shared/Views/Chat/ChatItemView.swift | 1 + .../Views/Helpers/ChatItemClipShape.swift | 1 + apps/ios/SimpleXChat/ChatTypes.swift | 18 ++++++++++ .../chat/simplex/common/model/ChatModel.kt | 15 +++++++++ .../common/views/chat/item/ChatItemView.kt | 4 +++ .../views/chat/item/IntegrityErrorItemView.kt | 14 ++++++++ .../commonMain/resources/MR/base/strings.xml | 4 +++ bots/api/TYPES.md | 31 +++++++++++++++++ bots/src/API/Docs/Types.hs | 4 +++ cabal.project | 2 +- .../types/typescript/src/types.ts | 33 +++++++++++++++++++ scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Subscriber.hs | 18 ++++++++-- src/Simplex/Chat/Messages/CIContent.hs | 22 +++++++++++++ src/Simplex/Chat/Protocol.hs | 8 +++-- src/Simplex/Chat/View.hs | 8 ++++- 17 files changed, 196 insertions(+), 7 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift index fdf3743aac..c816770c76 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -77,6 +77,24 @@ struct CIMsgError: View { } } +struct RcvMsgErrorItemView: View { + @ObservedObject var chat: Chat + var rcvMsgError: RcvMsgError + var chatItem: ChatItem + + var body: some View { + CIMsgError(chat: chat, chatItem: chatItem) { + AlertManager.shared.showAlertMsg( + title: "Message error", + message: switch rcvMsgError { + case let .dropped(attempts): "The app removed this message after \(attempts) attempts to receive it." + case let .parseError(parseError): "\(parseError)" + } + ) + } + } +} + struct IntegrityErrorItemView_Previews: PreviewProvider { static var previews: some View { IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample()) diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 138aed6c65..d0ff1934ba 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -145,6 +145,7 @@ struct ChatItemContentView: View { } else { ZStack {} } + case let .rcvMsgError(rcvMsgError): RcvMsgErrorItemView(chat: chat, rcvMsgError: rcvMsgError, chatItem: chatItem) case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(chat: chat, msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) diff --git a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift index 980308f13c..0491b38575 100644 --- a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift +++ b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift @@ -37,6 +37,7 @@ struct ChatItemClipped: ViewModifier { .rcvMsgContent, .rcvDecryptionError, .rcvIntegrityError, + .rcvMsgError, .invalidJSON: let tail = if let mc = ci.content.msgContent, mc.isImageOrVideo && mc.text.isEmpty { false diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 4a14d3ae99..99fdeebac4 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3271,6 +3271,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { case .rcvCall: return false // notification is shown on .callInvitation instead case .rcvIntegrityError: return false case .rcvDecryptionError: return false + case .rcvMsgError: return false case .rcvGroupInvitation: return true case .sndGroupInvitation: return false case .rcvDirectEvent(rcvDirectEvent: let rcvDirectEvent): @@ -4028,6 +4029,7 @@ public enum CIContent: Decodable, ItemContent, Hashable { case rcvCall(status: CICallStatus, duration: Int) case rcvIntegrityError(msgError: MsgErrorType) case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32) + case rcvMsgError(rcvMsgError: RcvMsgError) case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) case rcvDirectEvent(rcvDirectEvent: RcvDirectEvent) @@ -4065,6 +4067,7 @@ public enum CIContent: Decodable, ItemContent, Hashable { case let .rcvCall(status, duration): return status.text(duration) case let .rcvIntegrityError(msgError): return msgError.text case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text + case let .rcvMsgError(rcvMsgError): return rcvMsgError.text case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text @@ -4156,6 +4159,7 @@ public enum CIContent: Decodable, ItemContent, Hashable { case .rcvCall: return true case .rcvIntegrityError: return true case .rcvDecryptionError: return true + case .rcvMsgError: return true case .rcvGroupInvitation: return true case .rcvModerated: return true case .rcvBlocked: return true @@ -5082,6 +5086,20 @@ public enum MsgErrorType: Decodable, Hashable { } } +public enum RcvMsgError: Decodable, Hashable { + case dropped(attempts: Int) + case parseError(parseError: String) + + var text: String { + switch self { + case let .dropped(attempts): + String.localizedStringWithFormat(NSLocalizedString("removed (%d attempts)", comment: "receive error chat item"), attempts) + case let .parseError(parseError): + String.localizedStringWithFormat(NSLocalizedString("error: %@", comment: "receive error chat item"), parseError) + } + } +} + public struct CIGroupInvitation: Decodable, Hashable { public var groupId: Int64 public var groupMemberId: Int64 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 4e406044e5..f89a599bb4 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 @@ -3040,6 +3040,7 @@ data class ChatItem ( is CIContent.RcvCall -> false // notification is shown on CallInvitation instead is CIContent.RcvIntegrityError -> false is CIContent.RcvDecryptionError -> false + is CIContent.RcvMsgErrorContent -> false is CIContent.RcvGroupInvitation -> true is CIContent.SndGroupInvitation -> false is CIContent.RcvDirectEventContent -> when (content.rcvDirectEvent) { @@ -3734,6 +3735,7 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvMsgError") class RcvMsgErrorContent(val rcvMsgError: RcvMsgError): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvDirectEvent") class RcvDirectEventContent(val rcvDirectEvent: RcvDirectEvent): CIContent() { override val msgContent: MsgContent? get() = null } @@ -3770,6 +3772,7 @@ sealed class CIContent: ItemContent { is RcvCall -> status.text(duration) is RcvIntegrityError -> msgError.text is RcvDecryptionError -> msgDecryptError.text + is RcvMsgErrorContent -> rcvMsgError.text is RcvGroupInvitation -> groupInvitation.text is SndGroupInvitation -> groupInvitation.text is RcvDirectEventContent -> rcvDirectEvent.text @@ -3810,6 +3813,7 @@ sealed class CIContent: ItemContent { is RcvCall -> true is RcvIntegrityError -> true is RcvDecryptionError -> true + is RcvMsgErrorContent -> true is RcvGroupInvitation -> true is RcvModerated -> true is RcvBlocked -> true @@ -4721,6 +4725,17 @@ sealed class MsgErrorType() { } } +@Serializable +sealed class RcvMsgError() { + @Serializable @SerialName("dropped") class Dropped(val attempts: Int): RcvMsgError() + @Serializable @SerialName("parseError") class ParseError(val parseError: String): RcvMsgError() + + val text: String get() = when (this) { + is Dropped -> String.format(generalGetString(MR.strings.rcv_msg_error_dropped), attempts) + is ParseError -> String.format(generalGetString(MR.strings.rcv_msg_error_parse), parseError) + } +} + @Serializable sealed class RcvDirectEvent() { @Serializable @SerialName("contactDeleted") class ContactDeleted(): RcvDirectEvent() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index d2d0a91c9b..d5e2110063 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -714,6 +714,9 @@ fun ChatItemView( } else { Box(Modifier.size(0.dp)) {} } + is CIContent.RcvMsgErrorContent -> { + RcvMsgErrorItemView(c.rcvMsgError, cItem, showTimestamp, cInfo.timedMessagesTTL) + } is CIContent.RcvDecryptionError -> { CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) DeleteItemMenu() @@ -1310,6 +1313,7 @@ fun shapeStyleWithTail(chatItem: ChatItem? = null, tailEnabled: Boolean, tailVis is CIContent.SndMsgContent, is CIContent.RcvMsgContent, is CIContent.RcvDecryptionError, + is CIContent.RcvMsgErrorContent, is CIContent.SndDeleted, is CIContent.RcvDeleted, is CIContent.RcvIntegrityError, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt index d528396193..08b6520dfa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatItem import chat.simplex.common.model.MsgErrorType +import chat.simplex.common.model.RcvMsgError import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString @@ -73,6 +74,19 @@ fun CIMsgError(ci: ChatItem, showTimestamp: Boolean, timedMessagesTTL: Int?, onC } } +@Composable +fun RcvMsgErrorItemView(rcvMsgError: RcvMsgError, ci: ChatItem, showTimestamp: Boolean, timedMessagesTTL: Int?) { + CIMsgError(ci, showTimestamp, timedMessagesTTL) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.alert_title_msg_error), + text = when (rcvMsgError) { + is RcvMsgError.Dropped -> String.format(generalGetString(MR.strings.alert_text_msg_reception_error), rcvMsgError.attempts) + is RcvMsgError.ParseError -> rcvMsgError.parseError + } + ) + } +} + @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode" 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 609b6c4b3e..9ea32d5131 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1377,6 +1377,10 @@ bad message hash bad message ID duplicate message + dropped (%1$d attempts) + error: %s + Message error + The app removed this message after %1$d attempts to receive it. Skipped messages It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised. Bad message hash diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 7a68fa92a9..aee16ac5ea 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -70,6 +70,7 @@ This file is generated automatically. - [CreatedConnLink](#createdconnlink) - [CryptoFile](#cryptofile) - [CryptoFileArgs](#cryptofileargs) +- [DroppedMsg](#droppedmsg) - [E2EInfo](#e2einfo) - [ErrorType](#errortype) - [FeatureAllowed](#featureallowed) @@ -149,6 +150,7 @@ This file is generated automatically. - [RcvFileStatus](#rcvfilestatus) - [RcvFileTransfer](#rcvfiletransfer) - [RcvGroupEvent](#rcvgroupevent) +- [RcvMsgError](#rcvmsgerror) - [RelayProfile](#relayprofile) - [RelayStatus](#relaystatus) - [ReportReason](#reportreason) @@ -454,6 +456,10 @@ RcvDecryptionError: - msgDecryptError: [MsgDecryptError](#msgdecrypterror) - msgCount: word32 +RcvMsgError: +- type: "rcvMsgError" +- rcvMsgError: [RcvMsgError](#rcvmsgerror) + RcvGroupInvitation: - type: "rcvGroupInvitation" - groupInvitation: [CIGroupInvitation](#cigroupinvitation) @@ -1813,6 +1819,15 @@ connFullLink + ((' ' + connShortLink) if connShortLink is not None else '') # Py - fileNonce: string +--- + +## DroppedMsg + +**Record type**: +- brokerTs: UTCTime +- attempts: int + + --- ## E2EInfo @@ -3192,6 +3207,21 @@ MsgBadSignature: - type: "msgBadSignature" +--- + +## RcvMsgError + +**Discriminated union type**: + +Dropped: +- type: "dropped" +- attempts: int + +ParseError: +- type: "parseError" +- parseError: string + + --- ## RelayProfile @@ -3261,6 +3291,7 @@ A_CRYPTO: A_DUPLICATE: - type: "A_DUPLICATE" +- droppedMsg_: [DroppedMsg](#droppedmsg)? A_QUEUE: - type: "A_QUEUE" diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index e55e037243..826b8c1957 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -253,6 +253,7 @@ chatTypesDocsData = (sti @ContactUserPreferences, STRecord, "", [], "", ""), (sti @CryptoFile, STRecord, "", [], "", ""), (sti @CryptoFileArgs, STRecord, "", [], "", ""), + (sti @DroppedMsg, STRecord, "", [], "", ""), (sti @E2EInfo, STRecord, "", [], "", ""), (sti @ErrorType, STUnion, "", [], "", ""), (sti @FeatureAllowed, STEnum, "FA", [], "", ""), @@ -332,6 +333,7 @@ chatTypesDocsData = (sti @RcvFileStatus, STUnion, "RFS", [], "", ""), (sti @RcvFileTransfer, STRecord, "", [], "", ""), (sti @RcvGroupEvent, STUnion, "RGE", [], "", ""), + (sti @RcvMsgError, STUnion, "RME", [], "", ""), (sti @RelayProfile, STRecord, "", [], "", ""), (sti @RelayStatus, STEnum, "RS", [], "", ""), (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), @@ -452,6 +454,7 @@ deriving instance Generic ContactStatus deriving instance Generic ContactUserPreferences deriving instance Generic CryptoFile deriving instance Generic CryptoFileArgs +deriving instance Generic DroppedMsg deriving instance Generic E2EInfo deriving instance Generic ErrorType deriving instance Generic FeatureAllowed @@ -537,6 +540,7 @@ deriving instance Generic RcvFileDescr deriving instance Generic RcvFileStatus deriving instance Generic RcvFileTransfer deriving instance Generic RcvGroupEvent +deriving instance Generic RcvMsgError deriving instance Generic RelayProfile deriving instance Generic RelayStatus deriving instance Generic ReportReason diff --git a/cabal.project b/cabal.project index e853187e42..43ea745379 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 97802a30fce1dfeea90f0b465e21fc8eca937abb + tag: 0933cbcb9ce67e055f5230a8d5e07f5cb41f887d source-repository-package type: git diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index ef232a6461..621a69dcc8 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -278,6 +278,7 @@ export type CIContent = | CIContent.RcvCall | CIContent.RcvIntegrityError | CIContent.RcvDecryptionError + | CIContent.RcvMsgError | CIContent.RcvGroupInvitation | CIContent.SndGroupInvitation | CIContent.RcvDirectEvent @@ -312,6 +313,7 @@ export namespace CIContent { | "rcvCall" | "rcvIntegrityError" | "rcvDecryptionError" + | "rcvMsgError" | "rcvGroupInvitation" | "sndGroupInvitation" | "rcvDirectEvent" @@ -383,6 +385,11 @@ export namespace CIContent { msgCount: number // word32 } + export interface RcvMsgError extends Interface { + type: "rcvMsgError" + rcvMsgError: RcvMsgError + } + export interface RcvGroupInvitation extends Interface { type: "rcvGroupInvitation" groupInvitation: CIGroupInvitation @@ -2075,6 +2082,11 @@ export interface CryptoFileArgs { fileNonce: string } +export interface DroppedMsg { + brokerTs: string // ISO-8601 timestamp + attempts: number // int +} + export interface E2EInfo { pqEnabled?: boolean } @@ -3606,6 +3618,26 @@ export namespace RcvGroupEvent { } } +export type RcvMsgError = RcvMsgError.Dropped | RcvMsgError.ParseError + +export namespace RcvMsgError { + export type Tag = "dropped" | "parseError" + + interface Interface { + type: Tag + } + + export interface Dropped extends Interface { + type: "dropped" + attempts: number // int + } + + export interface ParseError extends Interface { + type: "parseError" + parseError: string + } +} + export interface RelayProfile { displayName: string fullName: string @@ -3681,6 +3713,7 @@ export namespace SMPAgentError { export interface A_DUPLICATE extends Interface { type: "A_DUPLICATE" + droppedMsg_?: DroppedMsg } export interface A_QUEUE extends Interface { diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 1c660f4dfa..006f37104e 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."97802a30fce1dfeea90f0b465e21fc8eca937abb" = "1pgrfzir7www4f2h1byvzlihs6vird85m1mhwiinl0xaqsycha1m"; + "https://github.com/simplex-chat/simplexmq.git"."0933cbcb9ce67e055f5230a8d5e07f5cb41f887d" = "0mnqcfvv1hshknl5mv6b05sh6nylzsy98xmfgyczfnryqd88r2jc"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 2676224631..f5f7752ca4 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -493,7 +493,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "contact msg=error " <> eInfo <> " " <> tshow e - eToView (ChatError . CEException $ "error parsing chat message: " <> e) + createInternalChatItem user (CDDirectRcv ct') (CIRcvMsgError $ RMEParseError $ T.pack e) Nothing + `catchAllErrors` \_ -> pure () withRcpt <- checkSendRcpt ct' $ rights aChatMsgs -- not crucial to use ct'' from processEvent pure (withRcpt, False) where @@ -687,6 +688,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- error cannot be AUTH error here updateDirectItemsStatusMsgs ct conn (L.toList msgIds) (CISSndError $ agentSndError err) eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) + ERR (AGENT (A_DUPLICATE (Just DroppedMsg {brokerTs, attempts}))) -> + createInternalChatItem user (CDDirectRcv ct) (CIRcvMsgError $ RMEDropped attempts) (Just brokerTs) ERR err -> do eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () @@ -962,7 +965,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "group msg=error " <> eInfo <> " " <> tshow e - eToView (ChatError . CEException $ "error parsing chat message: " <> e) + if isRelay membership + then + eToView (ChatError . CEException $ "error parsing chat message: " <> e) + else + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvMsgError $ RMEParseError $ T.pack e) Nothing + `catchAllErrors` \_ -> pure () pure newDeliveryTasks processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> VerifiedMsg e -> CM (Maybe NewMessageDeliveryTask) processEvent gInfo' m' verifiedMsg = do @@ -1178,6 +1186,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> forM_ msgIds $ \msgId -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) newStatus `catchAll_` pure () eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) + ERR err@(AGENT (A_DUPLICATE (Just DroppedMsg {brokerTs, attempts}))) + | isRelay membership -> + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) + | otherwise -> do + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvMsgError $ RMEDropped attempts) (Just brokerTs) ERR err -> do eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index e2e878033d..b08cc5a991 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -145,6 +145,7 @@ data CIContent (d :: MsgDirection) where CIRcvCall :: CICallStatus -> Int -> CIContent 'MDRcv CIRcvIntegrityError :: MsgErrorType -> CIContent 'MDRcv CIRcvDecryptionError :: MsgDecryptError -> Word32 -> CIContent 'MDRcv + CIRcvMsgError :: RcvMsgError -> CIContent 'MDRcv CIRcvGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDRcv CISndGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDSnd CIRcvDirectEvent :: RcvDirectEvent -> CIContent 'MDRcv @@ -196,6 +197,11 @@ data MsgDecryptError | MDERatchetSync deriving (Eq, Show) +data RcvMsgError + = RMEDropped {attempts :: Int} + | RMEParseError {parseError :: Text} + deriving (Eq, Show) + ciRequiresAttention :: forall d. MsgDirectionI d => CIContent d -> Bool ciRequiresAttention content = case msgDirection @d of SMDSnd -> True @@ -205,6 +211,7 @@ ciRequiresAttention content = case msgDirection @d of CIRcvCall {} -> True CIRcvIntegrityError _ -> True CIRcvDecryptionError {} -> True + CIRcvMsgError _ -> False CIRcvGroupInvitation {} -> True CIRcvDirectEvent rde -> case rde of RDEContactDeleted -> False @@ -275,6 +282,7 @@ ciContentToText = \case CIRcvCall status duration -> "incoming call: " <> ciCallInfoText status duration CIRcvIntegrityError err -> msgIntegrityError err CIRcvDecryptionError err n -> msgDecryptErrorText err n + CIRcvMsgError err -> rcvMsgErrorText err CIRcvGroupInvitation groupInvitation memberRole -> "received " <> ciGroupInvitationToText groupInvitation memberRole CISndGroupInvitation groupInvitation memberRole -> "sent " <> ciGroupInvitationToText groupInvitation memberRole CIRcvDirectEvent event -> rcvDirectEventToText event @@ -421,6 +429,11 @@ msgIntegrityError = \case MsgBadHash -> "incorrect message hash" MsgDuplicate -> "duplicate message ID" +rcvMsgErrorText :: RcvMsgError -> Text +rcvMsgErrorText = \case + RMEDropped {attempts} -> "message removed after " <> tshow attempts <> " attempts" + RMEParseError {parseError} -> "message error: " <> parseError + msgDecryptErrorText :: MsgDecryptError -> Word32 -> Text msgDecryptErrorText err n = "decryption error, possibly due to the device change" @@ -457,6 +470,7 @@ data JSONCIContent | JCIRcvCall {status :: CICallStatus, duration :: Int} | JCIRcvIntegrityError {msgError :: MsgErrorType} | JCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32} + | JCIRcvMsgError {rcvMsgError :: RcvMsgError} | JCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | JCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | JCIRcvDirectEvent {rcvDirectEvent :: RcvDirectEvent} @@ -492,6 +506,7 @@ jsonCIContent = \case CIRcvCall status duration -> JCIRcvCall {status, duration} CIRcvIntegrityError err -> JCIRcvIntegrityError err CIRcvDecryptionError err n -> JCIRcvDecryptionError err n + CIRcvMsgError err -> JCIRcvMsgError err CIRcvGroupInvitation groupInvitation memberRole -> JCIRcvGroupInvitation {groupInvitation, memberRole} CISndGroupInvitation groupInvitation memberRole -> JCISndGroupInvitation {groupInvitation, memberRole} CIRcvDirectEvent rcvDirectEvent -> JCIRcvDirectEvent {rcvDirectEvent} @@ -527,6 +542,7 @@ aciContentJSON = \case JCIRcvCall {status, duration} -> ACIContent SMDRcv $ CIRcvCall status duration JCIRcvIntegrityError err -> ACIContent SMDRcv $ CIRcvIntegrityError err JCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n + JCIRcvMsgError err -> ACIContent SMDRcv $ CIRcvMsgError err JCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole JCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole JCIRcvDirectEvent {rcvDirectEvent} -> ACIContent SMDRcv $ CIRcvDirectEvent rcvDirectEvent @@ -563,6 +579,7 @@ data DBJSONCIContent | DBJCIRcvCall {status :: CICallStatus, duration :: Int} | DBJCIRcvIntegrityError {msgError :: DBMsgErrorType} | DBJCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32} + | DBJCIRcvMsgError {rcvMsgError :: RcvMsgError} | DBJCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | DBJCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | DBJCIRcvDirectEvent {rcvDirectEvent :: DBRcvDirectEvent} @@ -598,6 +615,7 @@ dbJsonCIContent = \case CIRcvCall status duration -> DBJCIRcvCall {status, duration} CIRcvIntegrityError err -> DBJCIRcvIntegrityError $ DBME err CIRcvDecryptionError err n -> DBJCIRcvDecryptionError err n + CIRcvMsgError err -> DBJCIRcvMsgError err CIRcvGroupInvitation groupInvitation memberRole -> DBJCIRcvGroupInvitation {groupInvitation, memberRole} CISndGroupInvitation groupInvitation memberRole -> DBJCISndGroupInvitation {groupInvitation, memberRole} CIRcvDirectEvent rde -> DBJCIRcvDirectEvent $ RDE rde @@ -633,6 +651,7 @@ aciContentDBJSON = \case DBJCIRcvCall {status, duration} -> ACIContent SMDRcv $ CIRcvCall status duration DBJCIRcvIntegrityError (DBME err) -> ACIContent SMDRcv $ CIRcvIntegrityError err DBJCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n + DBJCIRcvMsgError err -> ACIContent SMDRcv $ CIRcvMsgError err DBJCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole DBJCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole DBJCIRcvDirectEvent (RDE rde) -> ACIContent SMDRcv $ CIRcvDirectEvent rde @@ -693,6 +712,8 @@ $(JQ.deriveJSON defaultJSON ''E2EInfo) $(JQ.deriveJSON (enumJSON $ dropPrefix "MDE") ''MsgDecryptError) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RME") ''RcvMsgError) + $(JQ.deriveJSON (enumJSON $ dropPrefix "CIGIS") ''CIGroupInvitationStatus) $(JQ.deriveJSON defaultJSON ''CIGroupInvitation) @@ -751,6 +772,7 @@ toCIContentTag ciContent = case ciContent of CIRcvCall {} -> "rcvCall" CIRcvIntegrityError _ -> "rcvIntegrityError" CIRcvDecryptionError {} -> "rcvDecryptionError" + CIRcvMsgError _ -> "rcvMsgError" CIRcvGroupInvitation {} -> "rcvGroupInvitation" CISndGroupInvitation {} -> "sndGroupInvitation" CIRcvDirectEvent _ -> "rcvDirectEvent" diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 6d7a094430..3485849741 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -56,7 +56,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_) -import Simplex.Messaging.Compression (Compressed, compress1, decompress1) +import Simplex.Messaging.Compression (Compressed, compress1, decompress1, decompressedSize) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -799,7 +799,11 @@ parseChatMessages msg = case B.head msg of decodeCompressed :: ByteString -> [Either String AParsedMsg] decodeCompressed s = case smpDecode s of Left e -> [Left e] - Right (compressed :: L.NonEmpty Compressed) -> concatMap (either (\e -> [Left e]) parseUncompressed' . decompress1 maxDecompressedMsgLength) compressed + Right (compressed :: L.NonEmpty Compressed) -> case traverse decompressedSize compressed of + Nothing -> [Left "compressed size not specified"] + Just sizes + | sum sizes > maxDecompressedMsgLength -> [Left "decompressed size exceeds limit"] + | otherwise -> concatMap (either (\e -> [Left e]) parseUncompressed' . decompress1) compressed parseUncompressed' "" = [Left "empty string"] parseUncompressed' s = parseUncompressed (B.head s) s -- Binary batch format: '=' ( )* diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6959d3e562..3afe0ce0ce 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -671,6 +671,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa CIDirectRcv -> case content of CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta + CIRcvMsgError err -> viewRcvMsgError from err ts tz meta CIRcvGroupEvent {} -> showRcvItemProhibited from _ -> showRcvItem from where @@ -694,6 +695,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa rcvGroupItem m_ = case content of CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta + CIRcvMsgError err -> viewRcvMsgError from err ts tz meta CIRcvGroupInvitation {} | isJust m_ -> showRcvItemProhibited from CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m_) context meta [plainContent content] False CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m_) context meta [plainContent content] False @@ -715,6 +717,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa CILocalRcv -> case content of CIRcvMsgContent mc -> withLocalFile from $ rcvMsg from context mc CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta + CIRcvMsgError err -> viewRcvMsgError from err ts tz meta CIRcvGroupEvent {} -> showRcvItemProhibited from _ -> showRcvItem from where @@ -991,6 +994,9 @@ viewRcvIntegrityError from msgErr ts tz meta = receivedWithTime_ ts tz from [] m viewMsgIntegrityError :: MsgErrorType -> [StyledString] viewMsgIntegrityError err = [ttyError $ msgIntegrityError err] +viewRcvMsgError :: StyledString -> RcvMsgError -> CurrentTime -> TimeZone -> CIMeta c 'MDRcv -> [StyledString] +viewRcvMsgError from rcvErr ts tz meta = receivedWithTime_ ts tz from [] meta [ttyError $ rcvMsgErrorText rcvErr] False + viewInvalidConnReq :: [StyledString] viewInvalidConnReq = [ "", @@ -2656,7 +2662,7 @@ viewChatError isCmd logLevel testView = \case BRContent -> "content violates conditions of use" BROKER _ (NETWORK _) | not isCmd -> [] BROKER _ TIMEOUT | not isCmd -> [] - AGENT A_DUPLICATE -> [withConnEntity <> "error: AGENT A_DUPLICATE" | logLevel == CLLDebug || isCmd] + AGENT A_DUPLICATE {} -> [withConnEntity <> "error: AGENT A_DUPLICATE" | logLevel == CLLDebug || isCmd] AGENT (A_PROHIBITED e) -> [withConnEntity <> "error: AGENT A_PROHIBITED, " <> plain e | logLevel <= CLLWarning || isCmd] CONN NOT_FOUND _ -> [withConnEntity <> "error: CONN NOT_FOUND" | logLevel <= CLLWarning || isCmd] CRITICAL restart e -> [plain $ "critical error: " <> e] <> ["please restart the app" | restart] From 01c9343cdd5002da8c81b1c3c41a88a490f55be6 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 11 Apr 2026 20:13:32 +0100 Subject: [PATCH 8/9] desktop: use text pointer for text selection (#6787) * desktop: use text pointer for text selection * do not clear selection on ctrl-c * crlf --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../kotlin/chat/simplex/common/views/chat/TextSelection.kt | 6 ++++-- .../chat/simplex/common/views/chat/item/TextItemView.kt | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt index 4447bf9da2..626aea0806 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -312,7 +312,6 @@ fun BoxScope.SelectionHandler( manager.listState = listState manager.onCopySelection = { clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, linkMode))) - manager.clearSelection() showToast(generalGetString(MR.strings.copied)) } @@ -509,7 +508,10 @@ fun SelectionCopyButton() { .background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp)) .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(20.dp)) - .clickable { manager.onCopySelection?.invoke() } + .clickable { + manager.onCopySelection?.invoke() + manager.clearSelection() + } .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 9e8583a79b..b8691c70f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -299,7 +299,7 @@ fun MarkdownText ( } val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) { - val icon = remember { mutableStateOf(PointerIcon.Default) } + val icon = remember { mutableStateOf(PointerIcon.Text) } ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, onLongClick = { offset -> if (hasLinks) { @@ -336,7 +336,7 @@ fun MarkdownText ( if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) { PointerIcon.Hand } else { - PointerIcon.Default + PointerIcon.Text } }, shouldConsumeEvent = { offset -> @@ -431,7 +431,7 @@ private fun SelectableText( BasicText( text = text, - modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)), + modifier = modifier.pointerHoverIcon(PointerIcon.Text).then(selectionHighlight(selectionRange, text.length, layoutResult)), style = style, maxLines = maxLines, overflow = overflow, From 46c41382a690deed9620d63c38a9dd1226c94664 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 11 Apr 2026 21:01:37 +0100 Subject: [PATCH 9/9] core: 6.5.0.15 (simplexmq 6.5.0.15) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 43ea745379..65983d22a5 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 0933cbcb9ce67e055f5230a8d5e07f5cb41f887d + tag: bc5ea42bec3a63e46b191e4150dd79957f114e01 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 006f37104e..6449ac8900 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."0933cbcb9ce67e055f5230a8d5e07f5cb41f887d" = "0mnqcfvv1hshknl5mv6b05sh6nylzsy98xmfgyczfnryqd88r2jc"; + "https://github.com/simplex-chat/simplexmq.git"."bc5ea42bec3a63e46b191e4150dd79957f114e01" = "0lswj7crfnwzh4g28kgxxl7g4i2a9pn03pxj7sqfqy3vs83m3bax"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 621a784ed8..320e51c8af 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.5.0.14 +version: 6.5.0.15 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat