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