diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 2055a0ab99..d8347f156b 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -574,6 +574,8 @@ enum ChatCommand: ChatCmdProtocol { } else { "(_support)" } + case .reports: + "(reports, prohibited)" // can't use surrogate Reports scope } } @@ -910,6 +912,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case groupMembers(user: UserRef, group: SimpleXChat.Group) case memberAccepted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case memberSupportChatRead(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case memberSupportChatDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole) case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) @@ -959,6 +962,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .leftMemberUser: "leftMemberUser" case .groupMembers: "groupMembers" case .memberAccepted: "memberAccepted" + case .memberSupportChatRead: "memberSupportChatRead" case .memberSupportChatDeleted: "memberSupportChatDeleted" case .membersRoleUser: "membersRoleUser" case .membersBlockedForAllUser: "membersBlockedForAllUser" @@ -1004,6 +1008,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupMembers(u, group): return withUser(u, String(describing: group)) case let .memberAccepted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .memberSupportChatRead(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .memberSupportChatDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)") case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 33b3fd8009..a08f70fd53 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -667,20 +667,24 @@ final class ChatModel: ObservableObject { func getCIItemsModel(_ cInfo: ChatInfo, _ ci: ChatItem) -> ItemsModel? { let cInfoScope = cInfo.groupChatScope() - if let cInfoScope = cInfoScope { - switch cInfoScope { - case .memberSupport: - switch secondaryIM?.secondaryIMFilter { - case .none: - return nil - case let .groupChatScopeContext(groupScopeInfo): - return (cInfo.id == chatId && sameChatScope(cInfoScope, groupScopeInfo.toChatScope())) ? secondaryIM : nil - case let .msgContentTagContext(contentTag): - return (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil - } + return if let cInfoScope = cInfoScope { + switch (cInfoScope, secondaryIM?.secondaryIMFilter) { + case let (.memberSupport, .some(.groupChatScopeContext(groupScopeInfo))): + // Chat with member or Chat with admins opened (secondaryIM has .groupChatScopeContext filter), cInfo has matching scope + (cInfo.id == chatId && sameChatScope(cInfoScope, groupScopeInfo.toChatScope())) ? secondaryIM : nil + + case let (.memberSupport, .some(.msgContentTagContext(contentTag))): + // Reports view opened (secondaryIM has .msgContentTagContext(.report) filter), we process event (cInfo has proper .memberSupport scope) + (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil + + case let (.reports, .some(.msgContentTagContext(contentTag))): + // Reports view opened (secondaryIM has .msgContentTagContext(.report) filter), we process user action (cInfo has surrogate .reports scope) + (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil + default: + nil } } else { - return cInfo.id == chatId ? im : nil + cInfo.id == chatId ? im : nil } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index ae9f21e34b..6aff1e9c39 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1391,8 +1391,14 @@ func apiRejectContactRequest(contactReqId: Int64) async throws -> Contact? { throw r.unexpected } -func apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) async throws { - try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: scope)) +func apiChatRead(type: ChatType, id: Int64) async throws { + try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: nil)) +} + +func apiSupportChatRead(type: ChatType, id: Int64, scope: GroupChatScope) async throws -> (GroupInfo, GroupMember) { + let r: ChatResponse2 = try await chatSendCmd(.apiChatRead(type: type, id: id, scope: scope)) + if case let .memberSupportChatRead(_, groupInfo, member) = r { return (groupInfo, member) } + throw r.unexpected } func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ChatInfo { @@ -1729,7 +1735,7 @@ func markChatRead(_ im: ItemsModel, _ chat: Chat) async { do { if chat.chatStats.unreadCount > 0 { let cInfo = chat.chatInfo - try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope()) + try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId) await MainActor.run { withAnimation { ChatModel.shared.markAllChatItemsRead(im, cInfo) } } @@ -1754,6 +1760,20 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async { } } +func markSupportChatRead(_ groupInfo: GroupInfo, _ member: GroupMember) async { + do { + if member.supportChatNotRead { + let (updatedGroupInfo, updatedMember) = try await apiSupportChatRead(type: .group, id: groupInfo.apiId, scope: .memberSupport(groupMemberId_: member.groupMemberId)) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember) + ChatModel.shared.updateGroup(updatedGroupInfo) + } + } + } catch { + logger.error("markSupportChatRead apiChatRead error: \(responseError(error))") + } +} + func apiMarkChatItemsRead(_ im: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async { do { let updatedChatInfo = try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 2f8d6f2acd..fa53045391 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -60,6 +60,7 @@ struct ChatView: View { @State private var animatedScrollingInProgress: Bool = false @State private var showUserSupportChatSheet = false @State private var showCommandsMenu = false + @State private var supportChatMemberInfoLinkActive = false @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) @@ -178,6 +179,28 @@ struct ChatView: View { if im.showLoadingProgress == chat.id { ProgressView().scaleEffect(2) } + if case let .group(groupInfo, _) = chat.chatInfo, + case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter, + case let .memberSupport(groupMember_) = groupScopeInfo, + let groupMember = groupMember_ { + NavigationLink(isActive: $supportChatMemberInfoLinkActive) { + GroupMemberInfoView( + groupInfo: groupInfo, + chat: chat, + groupMember: GMember(groupMember), + scrollToItemId: $scrollToItemId, + openedFromSupportChat: true + ) + .navigationBarHidden(false) + .modifier(BackButton(disabled: Binding.constant(false)) { + supportChatMemberInfoLinkActive = false + }) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } } .safeAreaInset(edge: .top) { VStack(spacing: .zero) { @@ -211,18 +234,20 @@ struct ChatView: View { .confirmationDialog(selectedChatItems?.count == 1 ? "Archive report?" : "Archive \((selectedChatItems?.count ?? 0)) reports?", isPresented: $showArchiveSelectedReports, titleVisibility: .visible) { Button("For me", role: .destructive) { if let selected = selectedChatItems { - archiveReports(chat.chatInfo, selected.sorted(), false, deletedSelectedMessages) + archiveReports(chat, selected.sorted(), false, deletedSelectedMessages) } } if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive { Button("For all moderators", role: .destructive) { if let selected = selectedChatItems { - archiveReports(chat.chatInfo, selected.sorted(), true, deletedSelectedMessages) + archiveReports(chat, selected.sorted(), true, deletedSelectedMessages) } } } } - .appSheet(item: $selectedMember) { member in + .appSheet(item: $selectedMember, onDismiss: { + chatModel.secondaryIM = nil + }) { member in if case let .group(groupInfo, _) = chat.chatInfo { GroupMemberInfoView( groupInfo: groupInfo, @@ -335,7 +360,10 @@ struct ChatView: View { } .onChange(of: chatModel.secondaryPendingInviteeChatOpened) { opened in if im.secondaryIMFilter != nil && !opened { - dismiss() + Task { + try? await Task.sleep(nanoseconds: 650_000000) + dismiss() + } } } .onChange(of: chatModel.openAroundItemId) { openAround in @@ -459,7 +487,10 @@ struct ChatView: View { ChatInfoToolbar(chat: chat) .tint(theme.colors.primary) } - .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { + .appSheet(isPresented: $showChatInfoSheet, onDismiss: { + chatModel.secondaryIM = nil + theme = buildTheme() + }) { GroupChatInfoView( chat: chat, groupInfo: Binding( @@ -562,10 +593,16 @@ struct ChatView: View { switch groupScopeInfo { case let .memberSupport(groupMember_): if let groupMember = groupMember_ { - MemberSupportChatToolbar(groupMember: groupMember) + Button { + supportChatMemberInfoLinkActive = true + } label: { + MemberSupportChatToolbar(groupMember: groupMember) + } } else { textChatToolbar("Chat with admins") } + case .reports: + textChatToolbar("Member reports") } case let .msgContentTagContext(contentTag): switch contentTag { @@ -1881,14 +1918,14 @@ struct ChatView: View { .confirmationDialog(archivingReports?.count == 1 ? "Archive report?" : "Archive \(archivingReports?.count ?? 0) reports?", isPresented: $showArchivingReports, titleVisibility: .visible) { Button("For me", role: .destructive) { if let reports = self.archivingReports { - archiveReports(chat.chatInfo, reports.sorted(), false) + archiveReports(chat, reports.sorted(), false) self.archivingReports = [] } } if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive { Button("For all moderators", role: .destructive) { if let reports = self.archivingReports { - archiveReports(chat.chatInfo, reports.sorted(), true) + archiveReports(chat, reports.sorted(), true) self.archivingReports = [] } } @@ -2680,13 +2717,13 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe await MainActor.run { for di in deletedItems { if let toItem = di.toChatItem { - _ = ChatModel.shared.upsertChatItem(chat.chatInfo, toItem.chatItem) + _ = ChatModel.shared.upsertChatItem(chatInfo, toItem.chatItem) } else { ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem) } let deletedItem = di.deletedChatItem.chatItem if deletedItem.isActiveReport { - ChatModel.shared.decreaseGroupReportsCounter(chat.chatInfo.id) + ChatModel.shared.decreaseGroupReportsCounter(chatInfo.id) } } if let updatedChatInfo = deletedItems.last?.deletedChatItem.chatInfo { @@ -2701,8 +2738,9 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe } } -func archiveReports(_ chatInfo: ChatInfo, _ itemIds: [Int64], _ forAll: Bool, _ onSuccess: @escaping () async -> Void = {}) { +func archiveReports(_ chat: Chat, _ itemIds: [Int64], _ forAll: Bool, _ onSuccess: @escaping () async -> Void = {}) { if itemIds.count > 0 { + let chatInfo = chat.chatInfo Task { do { let deleted = try await apiDeleteReceivedReports( diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 872e65c7a3..d8929caa3e 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -98,7 +98,7 @@ struct GroupChatInfoView: View { memberSupportButton() } if groupInfo.canModerate { - GroupReportsChatNavLink(chat: chat, scrollToItemId: $scrollToItemId) + GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) } if groupInfo.membership.memberActive && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { @@ -612,15 +612,19 @@ struct GroupChatInfoView: View { } struct GroupReportsChatNavLink: View { - @EnvironmentObject var chatModel: ChatModel - @EnvironmentObject var theme: AppTheme - @State private var navLinkActive = false @ObservedObject var chat: Chat + @EnvironmentObject var theme: AppTheme + var groupInfo: GroupInfo + @EnvironmentObject var chatModel: ChatModel @Binding var scrollToItemId: ChatItem.ID? + @State private var navLinkActive = false var body: some View { NavigationLink(isActive: $navLinkActive) { - SecondaryChatView(chat: chat, scrollToItemId: $scrollToItemId) + SecondaryChatView( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: .reports), chatItems: [], chatStats: ChatStats()), + scrollToItemId: $scrollToItemId + ) } label: { HStack { Label { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 2057b9b43c..2298af614e 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -18,6 +18,7 @@ struct GroupMemberInfoView: View { @ObservedObject var groupMember: GMember @Binding var scrollToItemId: ChatItem.ID? var navigation: Bool = false + var openedFromSupportChat: Bool = false @State private var connectionStats: ConnectionStats? = nil @State private var connectionCode: String? = nil @State private var connectionLoaded: Bool = false @@ -101,7 +102,8 @@ struct GroupMemberInfoView: View { if member.memberActive { Section { - if groupInfo.membership.memberRole >= .moderator + if !openedFromSupportChat + && groupInfo.membership.memberRole >= .moderator && (member.memberRole < .moderator || member.supportChat != nil) { MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift index 440ed5227d..cdbed7fe30 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift @@ -107,6 +107,8 @@ struct GroupMentionsView: View { } else { return member.memberRole >= .moderator } + case .reports: + return false } case .msgContentTagContext: return false diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 7f3672ea17..2101202ed8 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -92,6 +92,16 @@ struct MemberSupportView: View { .frame(width: 1, height: 1) .hidden() } + .if(!memberWithChat.wrapped.memberPending && memberWithChat.wrapped.supportChatNotRead) { v in + v.swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { + Task { await markSupportChatRead(groupInfo, memberWithChat.wrapped) } + } label: { + Label("Read", systemImage: "checkmark") + } + .tint(theme.colors.primary) + } + } .swipeActions(edge: .trailing, allowsFullSwipe: true) { if memberWithChat.wrapped.memberPending { Button { diff --git a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift index 47c5df264f..e2092f7a24 100644 --- a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift +++ b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct SecondaryChatView: View { + @Environment(\.dismiss) var dismiss @EnvironmentObject var chatModel: ChatModel @ObservedObject var chat: Chat @Binding var scrollToItemId: ChatItem.ID? @@ -23,9 +24,10 @@ struct SecondaryChatView: View { floatingButtonModel: FloatingButtonModel(im: im), scrollToItemId: $scrollToItemId ) - .onDisappear { + .modifier(BackButton(disabled: Binding.constant(false)) { chatModel.secondaryIM = nil - } + dismiss() + }) } } } diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index 2b3e8068ae..4dad9d5d15 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -160,6 +160,8 @@ enum SEChatCommand: ChatCmdProtocol { } else { "(_support)" } + case .reports: + "(reports, prohibited)" // can't use surrogate Reports scope } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 41e58f7c57..8c73563534 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.4.2-IWC91cESq2l3JFgl07ryw1-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-IWC91cESq2l3JFgl07ryw1-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-IWC91cESq2l3JFgl07ryw1.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-IWC91cESq2l3JFgl07ryw1.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN.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 */; }; @@ -555,8 +555,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.4.2-IWC91cESq2l3JFgl07ryw1-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.4.2-IWC91cESq2l3JFgl07ryw1-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-IWC91cESq2l3JFgl07ryw1.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.4.2-IWC91cESq2l3JFgl07ryw1.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN.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 = ""; }; @@ -718,8 +718,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-IWC91cESq2l3JFgl07ryw1-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-IWC91cESq2l3JFgl07ryw1.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -805,8 +805,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-IWC91cESq2l3JFgl07ryw1-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-IWC91cESq2l3JFgl07ryw1.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index aed5373664..9695e8e911 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1584,6 +1584,8 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { return nil case .some(.memberSupport(groupMember_: .none)): return nil + case .some(.reports): + return ("can't send messages", nil) } } else if groupInfo.nextConnectPrepared { return nil @@ -1895,26 +1897,35 @@ public struct ChatStats: Decodable, Hashable { public enum GroupChatScope: Decodable { case memberSupport(groupMemberId_: Int64?) + case reports // surrogate scope used for matching new items to opened Reports "chat scope" in UI, this type is not present in backend } public func sameChatScope(_ scope1: GroupChatScope, _ scope2: GroupChatScope) -> Bool { - switch (scope1, scope2) { + return switch (scope1, scope2) { case let (.memberSupport(groupMemberId1_), .memberSupport(groupMemberId2_)): - return groupMemberId1_ == groupMemberId2_ + groupMemberId1_ == groupMemberId2_ + case (.reports, .reports): + true + case (.reports, .memberSupport): + false + case (.memberSupport(groupMemberId_: let groupMemberId_), .reports): + false } } public enum GroupChatScopeInfo: Decodable, Hashable { case memberSupport(groupMember_: GroupMember?) + case reports // surrogate scope used for matching new items to opened Reports "chat scope" in UI, this type is not present in backend public func toChatScope() -> GroupChatScope { - switch self { + return switch self { case let .memberSupport(groupMember_): if let groupMember = groupMember_ { - return .memberSupport(groupMemberId_: groupMember.groupMemberId) + .memberSupport(groupMemberId_: groupMember.groupMemberId) } else { - return .memberSupport(groupMemberId_: nil) + .memberSupport(groupMemberId_: nil) } + case .reports: .reports } } } @@ -2634,7 +2645,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { } public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { - if !canBeRemoved(groupInfo: groupInfo) { return nil } + if !canBeRemoved(groupInfo: groupInfo) || memberPending { return nil } let userRole = groupInfo.membership.memberRole return GroupMemberRole.supportedRoles.filter { $0 <= userRole } } @@ -2643,12 +2654,22 @@ public struct GroupMember: Identifiable, Decodable, Hashable { let userRole = groupInfo.membership.memberRole return memberStatus != .memRemoved && memberStatus != .memLeft && memberRole < .moderator && userRole >= .moderator && userRole >= memberRole && groupInfo.membership.memberActive + && !memberPending } public var canReceiveReports: Bool { memberRole >= .moderator && versionRange.maxVersion >= REPORTS_VERSION } + public var supportChatNotRead: Bool { + if let supportChat = supportChat, + supportChat.memberAttention > 0 || supportChat.mentions > 0 || supportChat.unread > 0 { + true + } else { + false + } + } + public var versionRange: VersionRange { if let activeConn { activeConn.peerChatVRange 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 047d14d9f1..628e768d57 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 @@ -2343,7 +2343,7 @@ data class GroupMember ( } fun canChangeRoleTo(groupInfo: GroupInfo): List? = - if (!canBeRemoved(groupInfo)) null + if (!canBeRemoved(groupInfo) || memberPending) null else groupInfo.membership.memberRole.let { userRole -> GroupMemberRole.selectableRoles.filter { it <= userRole } } @@ -2352,8 +2352,15 @@ data class GroupMember ( val userRole = groupInfo.membership.memberRole return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft && memberRole < GroupMemberRole.Moderator && userRole >= GroupMemberRole.Moderator && userRole >= memberRole && groupInfo.membership.memberActive + && !memberPending } + val supportChatNotRead: Boolean get() = + if (supportChat != null) + supportChat.memberAttention > 0 || supportChat.mentions > 0 || supportChat.unread > 0 + else + false + val versionRange: VersionRange = activeConn?.peerChatVRange ?: memberChatVRange val memberIncognito = memberProfile.profileId != memberContactProfileId 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 9b60a968b7..30f3edb055 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 @@ -1849,13 +1849,20 @@ object ChatController { return null } - suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?): Boolean { - val r = sendCmd(rh, CC.ApiChatRead(type, id, scope)) + suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long): Boolean { + val r = sendCmd(rh, CC.ApiChatRead(type, id, scope = null)) if (r.result is CR.CmdOk) return true Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}") return false } + suspend fun apiSupportChatRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope): Pair? { + val r = sendCmd(rh, CC.ApiChatRead(type, id, scope)) + if (r is API.Result && r.res is CR.MemberSupportChatRead) return r.res.groupInfo to r.res.member + apiErrorAlert("apiSupportChatRead", generalGetString(MR.strings.error_marking_member_support_chat_read), r) + return null + } + suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List): ChatInfo? { val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, scope, itemIds)) if (r is API.Result && r.res is CR.ItemsReadForChat) return r.res.chatInfo @@ -2906,10 +2913,13 @@ object ChatController { && ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT) && chatModel.secondaryChatsContext.value?.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext ) { - withContext(Dispatchers.Main) { - chatModel.secondaryChatsContext.value = null + CoroutineScope(Dispatchers.Default).launch { + delay(1000L) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value = null + } + ModalManager.end.closeModals() } - ModalManager.end.closeModals() } } is CR.JoinedGroupMember -> @@ -6211,6 +6221,7 @@ sealed class CR { @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberAccepted") class MemberAccepted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("memberSupportChatRead") class MemberSupportChatRead(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("memberSupportChatDeleted") class MemberSupportChatDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("memberAcceptedByOther") class MemberAcceptedByOther(val user: UserRef, val groupInfo: GroupInfo, val acceptingMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() @@ -6395,6 +6406,7 @@ sealed class CR { is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" is MemberAccepted -> "memberAccepted" + is MemberSupportChatRead -> "memberSupportChatRead" is MemberSupportChatDeleted -> "memberSupportChatDeleted" is MemberAcceptedByOther -> "memberAcceptedByOther" is MemberRole -> "memberRole" @@ -6572,6 +6584,7 @@ sealed class CR { is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member") is MemberAccepted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is MemberSupportChatRead -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is MemberSupportChatDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is MemberAcceptedByOther -> withUser(user, "groupInfo: $groupInfo\nacceptingMember: $acceptingMember\nmember: $member") is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") 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 de48a67988..b69c98887d 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 @@ -453,7 +453,7 @@ fun ChatView( } ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, close, close) + GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close, close) } } } @@ -707,8 +707,7 @@ fun ChatView( chatModel.controller.apiChatRead( chatRh, chatInfo.chatType, - chatInfo.apiId, - chatInfo.groupChatScope() + chatInfo.apiId ) } withContext(Dispatchers.Main) { @@ -1005,7 +1004,9 @@ fun ChatLayout( 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) + if (chat != null) { + MemberSupportChatAppBar(chatsCtx, remoteHostId, chat, chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_, scrollToItemId, { ModalManager.end.closeModal() }, onSearchValueChanged) + } } else { SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value) } @@ -2941,12 +2942,6 @@ private fun archiveReports(chatRh: Long?, chatInfo: ChatInfo, itemIds: List remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, closeCurrent) { + GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, closeCurrent) { closeCurrent() close() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index e5f0b708e7..b29374390e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -49,9 +49,13 @@ fun GroupMemberInfoView( connectionStats: ConnectionStats?, connectionCode: String?, chatModel: ChatModel, + openedFromSupportChat: Boolean, close: () -> Unit, closeAll: () -> Unit, // Close all open windows up to ChatView ) { + KeyChangeEffect(chat.simplex.common.platform.chatModel.chatId.value) { + ModalManager.end.closeModals() + } BackHandler(onBack = close) val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId } val connStats = remember { mutableStateOf(connectionStats) } @@ -225,7 +229,8 @@ fun GroupMemberInfoView( ) } } - } + }, + openedFromSupportChat = openedFromSupportChat ) if (progressIndicator) { @@ -291,6 +296,7 @@ fun GroupMemberInfoLayout( syncMemberConnection: () -> Unit, syncMemberConnectionForce: () -> Unit, verifyClicked: () -> Unit, + openedFromSupportChat: Boolean ) { val cStats = connStats.value fun knownDirectChat(contactId: Long): Pair? { @@ -440,6 +446,7 @@ fun GroupMemberInfoLayout( if (member.memberActive) { SectionView { if ( + !openedFromSupportChat && groupInfo.membership.memberRole >= GroupMemberRole.Moderator && (member.memberRole < GroupMemberRole.Moderator || member.supportChat != null) ) { @@ -924,6 +931,7 @@ fun PreviewGroupMemberInfoLayout() { syncMemberConnection = {}, syncMemberConnectionForce = {}, verifyClicked = {}, + openedFromSupportChat = false, ) } } 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 99565618f9..6680ef99bc 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 @@ -51,7 +51,10 @@ private fun MemberSupportChatView( @Composable fun MemberSupportChatAppBar( chatsCtx: ChatModel.ChatsContext, + rhId: Long?, + chat: Chat, scopeMember_: GroupMember?, + scrollToItemId: MutableState, close: () -> Unit, onSearchValueChanged: (String) -> Unit ) { @@ -67,11 +70,31 @@ fun MemberSupportChatAppBar( } } BackHandler(onBack = onBackClicked) - if (scopeMember_ != null) { + if (chat.chatInfo is ChatInfo.Group && scopeMember_ != null) { + val groupInfo = chat.chatInfo.groupInfo DefaultAppBar( navigationButton = { NavigationButtonBack(onBackClicked) }, title = { MemberSupportChatToolbarTitle(scopeMember_) }, - onTitleClick = null, + onTitleClick = { + withBGApi { + val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, scopeMember_.groupMemberId) + val stats = r?.second + val code = if (scopeMember_.memberActive) { + val memCode = chatModel.controller.apiGetGroupMemberCode(rhId, groupInfo.apiId, scopeMember_.groupMemberId) + memCode?.second + } else { + null + } + ModalManager.end.showModalCloseable(true) { closeCurrent -> + remember { derivedStateOf { chatModel.getGroupMember(scopeMember_.groupMemberId) } }.value?.let { mem -> + GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = true, close = closeCurrent) { + closeCurrent() + close() + } + } + } + } + }, onTop = !oneHandUI.value || !chatBottomBar.value, showSearch = showSearch.value, onSearchValueChanged = onSearchValueChanged, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index 298a545c8c..e696128288 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -270,6 +270,12 @@ private fun DropDownMenuForSupportChat(rhId: Long?, member: GroupMember, groupIn showMenu.value = false }) } else { + if (member.supportChatNotRead) { + ItemAction(stringResource(MR.strings.mark_read), painterResource(MR.images.ic_check), color = MaterialTheme.colors.primary, onClick = { + markSupportChatRead(rhId, groupInfo, member) + showMenu.value = false + }) + } ItemAction(stringResource(MR.strings.delete_member_support_chat_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { deleteMemberSupportChatDialog(rhId, groupInfo, member) showMenu.value = false @@ -300,3 +306,22 @@ private fun deleteMemberSupportChat(rhId: Long?, groupInfo: GroupInfo, member: G } } } + +private fun markSupportChatRead(rhId: Long?, groupInfo: GroupInfo, member: GroupMember) { + withBGApi { + if (member.supportChatNotRead) { + val r = chatModel.controller.apiSupportChatRead( + rh = rhId, + type = ChatType.Group, + id = groupInfo.apiId, + scope = GroupChatScope.MemberSupport(member.groupMemberId) + ) + if (r != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.first, r.second) + chatModel.chatsContext.updateGroup(rhId, r.first) + } + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 2128d1991b..4a8e9e5193 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -648,8 +648,7 @@ fun markChatRead(c: Chat) { chatModel.controller.apiChatRead( chat.remoteHostId, chat.chatInfo.chatType, - chat.chatInfo.apiId, - chat.chatInfo.groupChatScope() + chat.chatInfo.apiId ) chat = chatModel.getChat(chat.id) ?: return@withApi } 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 151a2019be..401f69f9c0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -167,6 +167,7 @@ Error adding member(s) Error joining group Error accepting member + Error marking chat with member as read Error deleting chat with member Cannot receive file Sender cancelled file transfer. diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 290742ce03..8ae7f60b3d 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -66,7 +66,7 @@ crDirectoryEvent :: Either ChatError ChatEvent -> Maybe DirectoryEvent crDirectoryEvent = \case Right evt -> crDirectoryEvent_ evt Left e -> case e of - ChatErrorAgent {agentError = BROKER _ NETWORK} -> Nothing + ChatErrorAgent {agentError = BROKER _ (NETWORK _)} -> Nothing ChatErrorAgent {agentError = BROKER _ TIMEOUT} -> Nothing _ -> Just $ DELogChatResponse $ "chat error: " <> tshow e diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 4743ccb881..8353359484 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -121,6 +121,7 @@ This file is generated automatically. - [MsgFilter](#msgfilter) - [MsgReaction](#msgreaction) - [MsgReceiptStatus](#msgreceiptstatus) +- [NetworkError](#networkerror) - [NewUser](#newuser) - [NoteFolder](#notefolder) - [PendingContactConnection](#pendingcontactconnection) @@ -343,6 +344,7 @@ UNEXPECTED: NETWORK: - type: "NETWORK" +- networkError: [NetworkError](#networkerror) HOST: - type: "HOST" @@ -2635,6 +2637,34 @@ Unknown: - "badMsgHash" +--- + +## NetworkError + +**Discriminated union type**: + +ConnectError: +- type: "connectError" +- connectError: string + +TLSError: +- type: "tLSError" +- tlsError: string + +UnknownCAError: +- type: "unknownCAError" + +FailedError: +- type: "failedError" + +TimeoutError: +- type: "timeoutError" + +SubscribeError: +- type: "subscribeError" +- subscribeError: string + + --- ## NewUser diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 1c2e4baec3..8d5cb9f348 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -162,6 +162,7 @@ undocumentedResponses = "CRGroupUserChanged", "CRItemsReadForChat", "CRJoinedGroupMember", + "CRMemberSupportChatRead", "CRMemberSupportChatDeleted", "CRMemberSupportChats", "CRNetworkConfig", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 758fc62d53..83675798af 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -42,7 +42,7 @@ import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Client import Simplex.Messaging.Crypto.File import Simplex.Messaging.Parsers (dropPrefix, fstToLower) -import Simplex.Messaging.Protocol (BlockingInfo (..), BlockingReason (..), CommandError (..), ErrorType (..), ProxyError (..)) +import Simplex.Messaging.Protocol (BlockingInfo (..), BlockingReason (..), CommandError (..), ErrorType (..), NetworkError (..), ProxyError (..)) import Simplex.Messaging.Transport import Simplex.RemoteControl.Types import System.Console.ANSI.Types (Color (..)) @@ -299,6 +299,7 @@ chatTypesDocsData = (sti @MsgFilter, STEnum, "MF", [], "", ""), (sti @MsgReaction, STUnion, "MR", [], "", ""), (sti @MsgReceiptStatus, STEnum, "MR", [], "", ""), + (sti @NetworkError, STUnion, "NE", [], "", ""), (sti @NewUser, STRecord, "", [], "", ""), (sti @NoteFolder, STRecord, "", [], "", ""), (sti @PendingContactConnection, STRecord, "", [], "", ""), @@ -492,6 +493,7 @@ deriving instance Generic MsgErrorType deriving instance Generic MsgFilter deriving instance Generic MsgReaction deriving instance Generic MsgReceiptStatus +deriving instance Generic NetworkError deriving instance Generic NewUser deriving instance Generic NoteFolder deriving instance Generic PendingContactConnection diff --git a/cabal.project b/cabal.project index 07a0b427e8..8a861a1909 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: a2d777bda0af2a7ee7cd68952eaf7c86329427ad + tag: 7e98b3103f4eb9a6a9a99604afb6f3a32ffc013d source-repository-package type: git diff --git a/docs/rfcs/2025-08-09-chat-widgets.md b/docs/rfcs/2025-08-09-chat-widgets.md new file mode 100644 index 0000000000..6e9a13737c --- /dev/null +++ b/docs/rfcs/2025-08-09-chat-widgets.md @@ -0,0 +1,197 @@ +# Chat widgets and activities + +## Problems + +A short-term problem is to decide and implement support for bots in the apps UI. Just released v6.4.3 includes support for bot commands, but the most commonly used UI approach for Telegram bots is inline buttons. + +They are, effectively, simple widgets constructed as multiple lines of buttons. While currently Telegram offers web apps for bots, they are much more complex to develop for both ourselves and for bot owners, and they have many issues with security model. + +Inline buttons "pros": +- super simple to develop for bot owners - they are just a 2D array of buttons with configuration, where a button can send a visible or invisible message to a bot, which in turn can update buttons however it wants. +- allow for quite advanced interfaces, with multiple layers of navigation. +- relatively simple to implement in the app. + +Inline buttons "cons": +- very bad visual design. +- very limiting, compared with full inline widgets. + +A longer-term problem is more advanced user activities with the bot and between each other, that could include: +- polls, +- "doodles" (see doodle.com), +- more advanced bot UIs, +- mini-games, +- etc. + +## Solution + +A general UX pattern that may solve both problems is "inline chat widgets". + +For the examples of the possible use cases for inline chat widgets see https://webxdc.org/apps/ + +Problems with web apps/webxdc. +- JavaScript/Web have large binary size, that is hard to justify unless an app is a browser as well (which is not impossible), +- JavaScript has a complex security model, +- Widget size are likely to be larger than our usual message size (~15kb after compression), so they have to be sent either as files or as multiple messages. + +Irrespective of what technology is used to implement widgets, there are likely to be two kinds of widgets: +- Bot UI. These widgets do not necessarily need to be "an activity" (see the next), as there is no much scrolling in the chat with the bot, but they may also benefit from being marked as "active" in the same way. +- Widgets sent by users. In this case widget once sent cannot be replaced, but it can react to events, both from the sender and from the recipients. In this case, widgets have to be linked with "chat activities", so they can be easily discovered and accessed from any place in the chat while they are active, without scrolling to the point where they were started, and with an additional message posted to the chat once they complete. + +This RFC describes both the widget security and execution model, and also a possible implementation approach. + +## Widget security and execution models + +Widgets are code that is sent by untrusted parties (or parties with the limited trust) to the users devices, so it should not be treated as trusted code. + +Rather than defining what widgets code should be prevented from doing, we should define what it can do, its execution model and lifecycle. + +At any point in time widget has: +- code - this is fixed, and cannot be changed. While this may seem as a limitation for inline buttons UI, but it can be overcome by putting the button definitions into the state, if that's what the bot wants. +- state - this is variable, and can be changed as described below. State can be anything, other than executable code, and it can be changed by events in the way widget code allows - including full state replacement (that would allow completely changing inline buttons) + +Widget can react to user and message, as shown on the diagram. + +![Widget events](./diagrams/2025-08-09-widget-state-machine.svg) + +WL stands for "Widget Library", more on that below. + +There are should be the following restrictions to widget events/processing: +- only user events (actions) can trigger sending messages, to prevent different instances of the same widget in the chat endlessly "talking" to each other. +- only one message from each remote chat peer can be sent "to the widget" to update its state. "To the widget" means that the message with event would reference the message with the widget by shared message ID that all peers in the chat have. If the widget already processed a state update it would update further state updates from this peer until user action is processed. +- once user performs some actions on the widget, further events can be processed from the same participants who previously sent events. + +This execution model prevents abuse when widget state update can be requested multiple times. At the same time, this execution model allows for all necessary interactions, including UI updates by bot in response to user actions, polls (each user would only be able to send one poll event), and two- or multi-party games where "moves" have to be made in turns - each client would know that it should not send any events until it receives moves from all parties, and other parties won't be sending events too. + +That all raises several questions: +- which layer should be enforcing this execution model. It can't be widget "code", as this is what we are defending from. It can be either core, or widget library, or maybe both. Probably enforcing it in widget library makes most sense. +- for direct chats, both peers can participate and send message events "to widget". While each party has its own instance of widget, with its own state, they would arrive to consistent state (not necessarily the same state, as it can be programmed to be different - as would be the case for games), once they process events. But for groups, there probably needs to be two options - 1. any party can participate (e.g., doodle or poll). 2. only pre-defined peers (by group member ID) can participate. This model works for business chat where bot, customer and multiple business agents can participate, but only customer and the bot would interact with the UI widget. 3. up to a certain number of peers can participate, but it's not defined in advance who they are. This model can work for multi-party games that can be entered by a certain number of members, but it's not defined in advance which ones. Once they enter, they would be fixed and will have to send events in turn. +- another question about execution model is access to any client data. On one hand, it may be used to improve user experience. On another hand, we have to ensure that data that is received from client device can participate in view computation, but cannot participate in computation of sent message. There have been ideas of data tainting (when each piece from client device is "tainted", and result of any computation where tainted data participates becomes tainted too, and tainted messages cannot be sent), but if we go this it has to be enforced outside of bot code, so that bot code cannot remove "tainting". Such client data could be dark or light color scheme, app and system language to localize UI, timezone, screen size. Asking user permission to access this data is a bad idea, as even if it is granted it can still be used to fingerprint users. + +## Proposed implementation model + +### Widget programming language + +Using JavaScript or any other traditional language is problematic, as they are all general purpose languages that cannot be sufficiently constrained to ensure that they comply with the execution model. Data tainting idea would be particularly hard to implement. One language that can achieve what is required is the language where code is data that can be analyzed, sanitized and constrained in its execution, and where tainted data cannot be untainted by untrusted code - Lisp. One variant of Lisp particularly stands out - PicoLisp, due to its simplicity, maturity and the existence of very advanced libraries. + +While in its current state [PicoLisp](https://picolisp.com/) can only be run as a standalone process, it is feasible to change to execute it as a library. Even though it is not multithreaded, it is not required as widget execution can be queued, and Lisp execution environment will be stateless - it receives widget state, participation state (who can participate and who already sent events - not in diagram) and events, computes new state, view and an optional message to send, and stops. In addition to that PicoLisp can be "hardened" to prevent it from crashing, from accessing files and sensors, etc., even in the library code, not only in widget code. + +To achieve the required security, it is might be that Widget code needs to be interpreted by Lisp library that in its turn needs to be interpreted by PicoLisp, but possibly there can be more efficient approaches for secure code execution - e.g., it could analyze some part of the expressions and decide if to continue execution on the boundaries of some functions (e.g., those that can be called recursively, to protect against endless recursion of widget code). + +The design of this "framework"/widget library is not sufficiently clear, and some bottom-up exploration is needed. + +### Widget UI rendering + +PicoLisp has libraries to render on canvas, HTML and SVG. + +We considered using a UI library to render UI primitives without them being part of widget code. One possible option is [Nuklear](https://immediate-mode-ui.github.io/Nuklear/) - a self-contained C library that renders UI elements with event handlers in a platform-independent way, without any specific platform adapters. It can either return its own commands or OpenGL instructions that can then be converted to bitmaps. + +A simpler option, particularly to get to MVP sooner, seems an ad-hoc rendering of buttons and UX elements either to SVG or to bitmap - TBC. + +### A possible example of widget definition for inline buttons + +Widget function definition: + +```lisp +( de Activity (widget state) () # library code implementing activity from its definition + # where + # - widget a function without parameters evaluating to (render receive version), where: + # - render: function defined as '((state0) (...)), and evaluating to view medium, + # whatever we choose to use - e.g., SVG or bitmap, and any activity attributes + # (e.g., name, icon, enabled status, duration, etc.), e.g. as (view activity'), + # or maybe be as a single object value. + # Any user event/gesture handlers would be defined as part of this function, + # as they should be local to UI definition, and probably the event handlers + # would be required to return (state1 view message), + # where message can be NIL or it will be sent as visible message or invisible event. + # - receive: function defined as '((sender message state0) (...)) that will be + # invoked when message to widget is received, and evaluate to (state1 view) + # - ver: minimal library version that the activity requires to work + # (library would expose supported version range). + # - state: the initial widget state, can be updated by event handlers and receive function + # We don't explicitly include participants as parameter, they can be included in state. +) +``` + +Inline buttons widget: + +```lisp +( Activity + # ButtonGrid is a pre-defined widget function that returns: + # - render: converts buttons state to view + # - receive: simply replaces state with the message content, e.g. `((_sender message _state) message)` + # - version: (version 1) + # Bundling these things together results in better UX for widget developers, allowing us + # (and widget developers) to supply a single pre-defined symbol that combines + # rendering, message handling and required library version + + ButtonGrid + + # widget state is buttons, so they can be updated by bot in response to user actions + ( ( ("Reply" (reply "message")) # visible reply message (replying to widget) + ("Menu" (event ":menu")) # invisible message + ("Help" (send "/help")) # visible message + ) # a row of 3 buttons + ( ("Site" (link "https://example.com)) + ("Call" (link "+447777777777")) + ("Email" (link "info@example.com")) + ("Connect" (link "https://smp5.simplex.im/a#abcd)) + ("Copy" (copy "text to copy)) + ) # a row of 5 buttons + ) +) +``` + +Poll widget: + +```lisp +( Activity + # Poll is a predefined widget function. + # Note that the question and the poll options are part of the widget function, + # so they cannot be changed via events. + # Its receive function updates counts. + ( Poll + "Do you agree?" # could be NIL, to put question in widget's message text + "Yes" + "No" + ) + + # initial widget state is poll counts, questions could not be changed + (0 0) + # but we probably can just default missing counts to 0s, + # so the initial state would be just () or NIL + # Or we could even use variable arguments to allow omitting empty list as the initial state. +) +``` + +If we can achieve that these functions (ButtonGrid, Poll) can be implemented in PicoLisp using the library, without any special hacks, so that they can be used as examples, and yet they would also would be available as predefined, it will be a sufficient confirmation that the framework does what we need it to do, +and can be used in more advanced scenarios. + +Also, given that widgets that use predefined functions can be very concise, as shown in above examples, they can be attached not only to text messages, but to images, videos and link previews. + +Even `Activity` itself doesn't need to be part of widget message, we can treat widget code as the list of parameters passed to activity, so the poll would be as simple as: + +```lisp +((Poll "Do you agree?" "Yes" "No")) # the second absent parameter is interpreted as empty initial state +``` + +and the button grid as: + +```lisp +( ButtonGrid + ( ( ("Reply" (reply "message")) + ("Menu" (event ":menu")) + ("Help" (send "/help")) + ) + ( ("Site" (link "https://example.com)) + ("Call" (link "+447777777777")) + ("Email" (link "info@example.com")) + ("Connect" (link "https://smp5.simplex.im/a#abcd)) + ("Copy" (copy "text to copy)) + ) + ) +) +``` + +with the buttons definitions (the second parameter) being the initial state, that allows to have them fully replaced via bot's message. + +This is somehow similar to PicoLisp design itself where three core types are derived from a single root type - cell, here we have two distinct UX problems - bot UI and user activities, such as polls, - implemented via the same underlying abstraction. diff --git a/docs/rfcs/diagrams/2025-08-09-widget-state-machine.mmd b/docs/rfcs/diagrams/2025-08-09-widget-state-machine.mmd new file mode 100644 index 0000000000..cae598a0fb --- /dev/null +++ b/docs/rfcs/diagrams/2025-08-09-widget-state-machine.mmd @@ -0,0 +1,30 @@ +sequenceDiagram + participant Contact + participant UI + participant Core + participant DB + participant WL + note over Contact, WL: 1. Receive and initialize widget + Contact->>Core: widget code and initial state + Core->>DB: persist message with widget + Core->>UI: new message
(with widget placeholder) + Core->>WL: widget code + state + WL->>Core: new state + view + Core->>DB: persist new state + Core->>UI: view + note over Contact, WL: 2. Process user event + UI->>Core: widget user event
(UI "gesture") + DB->>Core: get code + state + Core->>WL: user event + code + state + WL->>Core: updated state + view + optional message + Core->>DB: updated state + Core->>UI: new widget view
(non-optional, for visual feedback) + Core->>Contact: optional event message "from" widget + note over Contact, WL: 3. Process user event + Contact->>Core: Message with event for widget + Core->>DB: persist message (to resume/retry) + DB->>Core: get code + state + Core->>WL: message event + code + state + WL->>Core: updated state + view + Core->>DB: updated state + Core->>UI: new widget view
(plus some visual indication) diff --git a/docs/rfcs/diagrams/2025-08-09-widget-state-machine.svg b/docs/rfcs/diagrams/2025-08-09-widget-state-machine.svg new file mode 100644 index 0000000000..c8c9dd7bb4 --- /dev/null +++ b/docs/rfcs/diagrams/2025-08-09-widget-state-machine.svg @@ -0,0 +1 @@ +WLDBCoreUIContactWLDBCoreUIContact1. Receive and initialize widget2. Process user event3. Process user eventwidget code and initial statepersist message with widgetnew message(with widget placeholder)widget code + statenew state + viewpersist new stateviewwidget user event(UI "gesture")get code + stateuser event + code + stateupdated state + view + optional messageupdated statenew widget view(non-optional, for visual feedback)optional event message "from" widgetMessage with event for widgetpersist message (to resume/retry)get code + statemessage event + code + stateupdated state + viewupdated statenew widget view(plus some visual indication) \ No newline at end of file diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 1511c3851f..3a056293c5 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -216,6 +216,7 @@ export namespace BrokerErrorType { export interface NETWORK extends Interface { type: "NETWORK" + networkError: NetworkError } export interface HOST extends Interface { @@ -2915,6 +2916,55 @@ export enum MsgReceiptStatus { BadMsgHash = "badMsgHash", } +export type NetworkError = + | NetworkError.ConnectError + | NetworkError.TLSError + | NetworkError.UnknownCAError + | NetworkError.FailedError + | NetworkError.TimeoutError + | NetworkError.SubscribeError + +export namespace NetworkError { + export type Tag = + | "connectError" + | "tLSError" + | "unknownCAError" + | "failedError" + | "timeoutError" + | "subscribeError" + + interface Interface { + type: Tag + } + + export interface ConnectError extends Interface { + type: "connectError" + connectError: string + } + + export interface TLSError extends Interface { + type: "tLSError" + tlsError: string + } + + export interface UnknownCAError extends Interface { + type: "unknownCAError" + } + + export interface FailedError extends Interface { + type: "failedError" + } + + export interface TimeoutError extends Interface { + type: "timeoutError" + } + + export interface SubscribeError extends Interface { + type: "subscribeError" + subscribeError: string + } +} + export interface NewUser { profile?: Profile pastTimestamp: boolean diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 6b6af1032b..630b178aaf 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,27 @@ + + https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html + +

New in v6.4.4:

+
    +
  • reduced battery usage.
  • +
  • fixes.
  • +
+

New in v6.4-6.4.3.1:

+
    +
  • new UX to connect.
  • +
  • review new group members.
  • +
  • chat with group admins.
  • +
  • new UI languages: Catalan, Indonesian, Romanian and Vietnamese.
  • +
  • Linux app builds for aarch64 CPUs
  • +
  • UI support for bot commands.
  • +
  • support markdown hyperlinks, such as [click here](https://example.com).
  • +
  • option to remove tracking parameters from the links.
  • +
+
+
https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 6558a66e31..a5f2790418 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."a2d777bda0af2a7ee7cd68952eaf7c86329427ad" = "04h8vdxf732jwsim2fcrql47gsmv680lgg2kylgmfk4al0pnpkdk"; + "https://github.com/simplex-chat/simplexmq.git"."7e98b3103f4eb9a6a9a99604afb6f3a32ffc013d" = "0g6lm65hs2kp2rsk9lqzj42nq51i5xynxrf16axma80cq0jqzxzl"; "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 8029915ace..463612cb69 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.4.2 +version: 6.4.5.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 2cbc941b44..b54c23959b 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -158,7 +158,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D -- closing after encryption prevents closing in case wrong encryption key was passed liftIO $ closeDBStore `withStores` fs (moveExported `withStores` fs) - `catchChatError` \e -> (restore `withDBs` fs) >> throwError e + `catchAllErrors` \e -> (restore `withDBs` fs) >> throwError e where backup f = copyFile f (f <> ".bak") restore f = copyFile (f <> ".bak") f diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 53d3839f42..d8a6f7a30a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -19,7 +19,7 @@ module Simplex.Chat.Controller where import Control.Concurrent (ThreadId) import Control.Concurrent.Async (Async) -import Control.Exception (Exception, SomeException) +import Control.Exception (Exception) import qualified Control.Exception as E import Control.Monad.Except import Control.Monad.IO.Unlift @@ -88,7 +88,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), Msg import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, TransportPeer (..), simplexMQVersion) import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost) -import Simplex.Messaging.Util (allFinally, catchAllErrors, catchAllErrors', tryAllErrors, tryAllErrors', (<$$>)) +import Simplex.Messaging.Util (AnyError (..), catchAllErrors, (<$$>)) import Simplex.RemoteControl.Client import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitation) import Simplex.RemoteControl.Types @@ -726,6 +726,7 @@ data ChatResponse | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMemberAccepted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRMemberSupportChatRead {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMemberSupportChatDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMembersRoleUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], toRole :: GroupMemberRole} | CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool} @@ -1419,6 +1420,10 @@ data ArchiveError | AEFileError {file :: String, fileError :: String} deriving (Show, Exception) +instance AnyError ChatError where + fromSomeException = ChatError . CEException . show + {-# INLINE fromSomeException #-} + -- | Host (mobile) side of transport to process remote commands and forward notifications data RemoteCtrlSession = RCSessionStarting @@ -1505,46 +1510,10 @@ setContactNetworkStatus :: Contact -> NetworkStatus -> CM' () setContactNetworkStatus Contact {activeConn = Nothing} _ = pure () setContactNetworkStatus Contact {activeConn = Just Connection {agentConnId}} status = chatModifyVar' connNetworkStatuses $ M.insert agentConnId status -tryChatError :: CM a -> CM (Either ChatError a) -tryChatError = tryAllErrors mkChatError -{-# INLINE tryChatError #-} - -tryChatError' :: CM a -> CM' (Either ChatError a) -tryChatError' = tryAllErrors' mkChatError -{-# INLINE tryChatError' #-} - -catchChatError :: CM a -> (ChatError -> CM a) -> CM a -catchChatError = catchAllErrors mkChatError -{-# INLINE catchChatError #-} - -catchChatError' :: CM a -> (ChatError -> CM' a) -> CM' a -catchChatError' = catchAllErrors' mkChatError -{-# INLINE catchChatError' #-} - -chatFinally :: CM a -> CM b -> CM a -chatFinally = allFinally mkChatError -{-# INLINE chatFinally #-} - onChatError :: CM a -> CM b -> CM a -a `onChatError` onErr = a `catchChatError` \e -> onErr >> throwError e +a `onChatError` onErr = a `catchAllErrors` \e -> onErr >> throwError e {-# INLINE onChatError #-} -mkChatError :: SomeException -> ChatError -mkChatError = ChatError . CEException . show -{-# INLINE mkChatError #-} - -catchStoreError :: ExceptT StoreError IO a -> (StoreError -> ExceptT StoreError IO a) -> ExceptT StoreError IO a -catchStoreError = catchAllErrors mkStoreError -{-# INLINE catchStoreError #-} - -tryStoreError' :: ExceptT StoreError IO a -> IO (Either StoreError a) -tryStoreError' = tryAllErrors' mkStoreError -{-# INLINE tryStoreError' #-} - -mkStoreError :: SomeException -> StoreError -mkStoreError = SEInternalError . show -{-# INLINE mkStoreError #-} - throwCmdError :: String -> CM a throwCmdError = throwError . ChatError . CECommandError {-# INLINE throwCmdError #-} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4ae58d8a29..d452255d71 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -230,7 +230,7 @@ startReceiveUserFiles :: User -> CM () startReceiveUserFiles user = do filesToReceive <- withStore' (`getRcvFilesToReceive` user) forM_ filesToReceive $ \ft -> - flip catchChatError eToView $ + flip catchAllErrors eToView $ toView =<< receiveFileEvt' user ft False Nothing Nothing restoreCalls :: CM' () @@ -300,7 +300,7 @@ handleCommandError a = runExceptT a `E.catches` ioErrors where ioErrors = [ E.Handler $ \(e :: ExitCode) -> E.throwIO e, - E.Handler $ pure . Left . mkChatError + E.Handler $ pure . Left . fromSomeException ] parseChatCommand :: ByteString -> Either String ChatCommand @@ -324,7 +324,7 @@ processChatCommand vr nm = \case user <- withFastStore $ \db -> do user <- createUserRecordAt db (AgentUserId auId) p True ts mapM_ (setUserServers db user ts) uss - createPresetContactCards db user `catchStoreError` \_ -> pure () + createPresetContactCards db user `catchAllErrors` \_ -> pure () createNoteFolder db user pure user atomically . writeTVar u $ Just user @@ -363,7 +363,7 @@ processChatCommand vr nm = \case chatWriteVar currentUser $ Just user'' pure $ CRActiveUser user'' SetActiveUser uName viewPwd_ -> do - tryChatError (withFastStore (`getUserIdByName` uName)) >>= \case + tryAllErrors (withFastStore (`getUserIdByName` uName)) >>= \case Left _ -> throwChatError CEUserUnknown Right userId -> processChatCommand vr nm $ APISetActiveUser userId viewPwd_ SetAllContactReceipts onOff -> withUser $ \_ -> withFastStore' (`updateAllContactReceipts` onOff) >> ok_ @@ -517,13 +517,36 @@ processChatCommand vr nm = \case pure $ CRApiChat user (AChat SCTDirect directChat) navInfo CTGroup -> do (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId scope_ contentFilter pagination search) - pure $ CRApiChat user (AChat SCTGroup groupChat) navInfo + groupChat' <- checkSupportChatAttention user groupChat + pure $ CRApiChat user (AChat SCTGroup groupChat') navInfo CTLocal -> do when (isJust contentFilter) $ throwCmdError "content filter not supported" (localChat, navInfo) <- withFastStore (\db -> getLocalChat db user cId pagination search) pure $ CRApiChat user (AChat SCTLocal localChat) navInfo CTContactRequest -> throwCmdError "not implemented" CTContactConnection -> throwCmdError "not supported" + where + checkSupportChatAttention :: User -> Chat 'CTGroup -> CM (Chat 'CTGroup) + checkSupportChatAttention user groupChat@Chat {chatInfo, chatItems} = + case chatInfo of + GroupChat gInfo (Just GCSIMemberSupport {groupMember_ = Just scopeMem@GroupMember {supportChat = Just suppChat}}) -> do + case correctedMemAttention (groupMemberId' scopeMem) suppChat chatItems of + Just newMemAttention -> do + (gInfo', scopeMem') <- + withFastStore' $ \db -> setSupportChatMemberAttention db vr user gInfo scopeMem newMemAttention + pure $ groupChat {chatInfo = GroupChat gInfo' (Just $ GCSIMemberSupport (Just scopeMem'))} + Nothing -> pure groupChat + _ -> pure groupChat + where + correctedMemAttention :: GroupMemberId -> GroupSupportChat -> [CChatItem 'CTGroup] -> Maybe Int64 + correctedMemAttention scopeGMId GroupSupportChat {memberAttention} items = + let numNewFromMember = fromIntegral . length . takeWhile newFromMember $ reverse items + in if numNewFromMember == memberAttention then Nothing else Just numNewFromMember + where + newFromMember :: CChatItem 'CTGroup -> Bool + newFromMember (CChatItem _ ChatItem {chatDir = CIGroupRcv m, meta = CIMeta {itemStatus = CISRcvNew}}) = + groupMemberId' m == scopeGMId + newFromMember _ = False APIGetChatItems pagination search -> withUser $ \user -> do chatItems <- withFastStore $ \db -> getAllChatItems db vr user pagination search pure $ CRChatItems user Nothing chatItems @@ -998,7 +1021,7 @@ processChatCommand vr nm = \case pure $ prefix <> formattedDate <> ext APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user UserRead -> withUser $ \User {userId} -> processChatCommand vr nm $ APIUserRead userId - APIChatRead chatRef@(ChatRef cType chatId scope) -> withUser $ \_ -> case cType of + APIChatRead chatRef@(ChatRef cType chatId scope_) -> withUser $ \_ -> case cType of CTDirect -> do user <- withFastStore $ \db -> getUserByContactId db chatId ts <- liftIO getCurrentTime @@ -1014,12 +1037,23 @@ processChatCommand vr nm = \case gInfo <- getGroupInfo db vr user chatId pure (user, gInfo) ts <- liftIO getCurrentTime - timedItems <- withFastStore' $ \db -> do - timedItems <- getGroupUnreadTimedItems db user chatId - updateGroupChatItemsRead db user gInfo scope - setGroupChatItemsDeleteAt db user chatId timedItems ts - forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt - ok user + case scope_ of + Nothing -> do + timedItems <- withFastStore' $ \db -> do + timedItems <- getGroupUnreadTimedItems db user chatId Nothing + updateGroupChatItemsRead db user gInfo + setGroupChatItemsDeleteAt db user chatId timedItems ts + forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt + ok user + Just scope -> do + scopeInfo <- getChatScopeInfo vr user scope + (gInfo', m', timedItems) <- withFastStore' $ \db -> do + timedItems <- getGroupUnreadTimedItems db user chatId (Just scope) + (gInfo', m') <- updateSupportChatItemsRead db vr user gInfo scopeInfo + timedItems' <- setGroupChatItemsDeleteAt db user chatId timedItems ts + pure (gInfo', m', timedItems') + forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt + pure $ CRMemberSupportChatRead user gInfo' m' CTLocal -> do user <- withFastStore $ \db -> getUserByNoteFolderId db chatId withFastStore' $ \db -> updateLocalChatItemsRead db user chatId @@ -1101,7 +1135,7 @@ processChatCommand vr nm = \case where sendDelDeleteConns ct notify = do let doSendDel = contactReady ct && contactActive ct && notify - when doSendDel $ void (sendDirectContactMessage user ct XDirectDel) `catchChatError` const (pure ()) + when doSendDel $ void (sendDirectContactMessage user ct XDirectDel) `catchAllErrors` const (pure ()) contactConnIds <- map aConnId <$> withFastStore' (\db -> getContactConnections db vr userId ct) deleteAgentConnectionsAsync' contactConnIds doSendDel CTContactConnection -> withConnectionLock "deleteChat contactConnection" chatId $ do @@ -1123,7 +1157,7 @@ processChatCommand vr nm = \case when doSendDel . void $ sendGroupMessage' user gInfo recipients XGrpDel deleteGroupLinkIfExists user gInfo deleteMembersConnections' user members doSendDel - updateCIGroupInvitationStatus user gInfo CIGISRejected `catchChatError` \_ -> pure () + updateCIGroupInvitationStatus user gInfo CIGISRejected `catchAllErrors` \_ -> pure () withFastStore' $ \db -> deleteGroupChatItems db user gInfo withFastStore' $ \db -> cleanupHostGroupLinkConn db user gInfo withFastStore' $ \db -> deleteGroupMembers db user gInfo @@ -1467,7 +1501,7 @@ processChatCommand vr nm = \case oldTTL = fromMaybe globalTTL oldTTL_ when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do lift $ setExpireCIFlag user False - expireChat user globalTTL `catchChatError` eToView + expireChat user globalTTL `catchAllErrors` eToView lift $ setChatItemsExpiration user globalTTL ttlCount ok user where @@ -1538,7 +1572,7 @@ processChatCommand vr nm = \case liftIO $ updateGroupSettings db user chatId chatSettings pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchChatError` eToView + withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchAllErrors` eToView ok user _ -> throwCmdError "not supported" APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do @@ -1829,7 +1863,7 @@ processChatCommand vr nm = \case case preparedContact of Nothing -> throwCmdError "contact doesn't have link to connect" Just PreparedContact {connLinkToConnect = ACCL SCMInvitation ccLink} -> do - (_, customUserProfile) <- connectViaInvitation user incognito ccLink (Just contactId) `catchChatError` \e -> do + (_, customUserProfile) <- connectViaInvitation user incognito ccLink (Just contactId) `catchAllErrors` \e -> do -- get updated contact, in case connection was started - in UI it would lock ability to change -- user or incognito profile for contact, in case server received request while client got network error ct' <- withFastStore $ \db -> getContact db vr user contactId @@ -1852,7 +1886,7 @@ processChatCommand vr nm = \case smId <- getSharedMsgId withFastStore' $ \db -> setRequestSharedMsgIdForContact db contactId smId pure (smId, mc) - r <- connectViaContact user (Just $ PCEContact ct) incognito ccLink welcomeSharedMsgId msg_ `catchChatError` \e -> do + r <- connectViaContact user (Just $ PCEContact ct) incognito ccLink welcomeSharedMsgId msg_ `catchAllErrors` \e -> do -- get updated contact, in case connection was started - in UI it would lock ability to change -- user or incognito profile for contact, in case server received request while client got network error ct' <- withFastStore $ \db -> getContact db vr user contactId @@ -1880,7 +1914,7 @@ processChatCommand vr nm = \case smId <- getSharedMsgId withFastStore' $ \db -> setRequestSharedMsgIdForGroup db groupId smId pure (smId, mc) - r <- connectViaContact user (Just $ PCEGroup gInfo hostMember) incognito connLinkToConnect welcomeSharedMsgId msg_ `catchChatError` \e -> do + r <- connectViaContact user (Just $ PCEGroup gInfo hostMember) incognito connLinkToConnect welcomeSharedMsgId msg_ `catchAllErrors` \e -> do -- get updated group info, in case connection was started (connLinkPreparedConnection) - in UI it would lock ability to change -- user or incognito profile for group or business chat, in case server received request while client got network error gInfo' <- withFastStore $ \db -> getGroupInfo db vr user groupId @@ -1908,7 +1942,7 @@ processChatCommand vr nm = \case CVRSentInvitation conn incognitoProfile -> pure $ CRSentInvitation user (mkPendingContactConnection conn Nothing) incognitoProfile APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq Connect incognito (Just cLink@(ACL m cLink')) -> withUser $ \user -> do - (ccLink, plan) <- connectPlan user cLink `catchChatError` \e -> case cLink' of CLFull cReq -> pure (ACCL m (CCLink cReq Nothing), CPInvitationLink (ILPOk Nothing)); _ -> throwError e + (ccLink, plan) <- connectPlan user cLink `catchAllErrors` \e -> case cLink' of CLFull cReq -> pure (ACCL m (CCLink cReq Nothing), CPInvitationLink (ILPOk Nothing)); _ -> throwError e connectWithPlan user incognito ccLink plan Connect _ Nothing -> throwChatError CEInvalidConnReq APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do @@ -1919,14 +1953,14 @@ processChatCommand vr nm = \case (cReq, _cData) <- getShortLinkConnReq user sLnk pure $ CCLink cReq $ Just sLnk Nothing -> throwCmdError "no address in contact profile" - connectContactViaAddress user incognito ct ccLink `catchChatError` \e -> do + connectContactViaAddress user incognito ct ccLink `catchAllErrors` \e -> do -- get updated contact, in case connection was started - in UI it would lock ability to change incognito choice -- on next connection attempt, in case server received request while client got network error ct' <- withFastStore $ \db -> getContact db vr user contactId toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') throwError e ConnectSimplex incognito -> withUser $ \user -> do - plan <- contactRequestPlan user adminContactReq Nothing `catchChatError` const (pure $ CPContactAddress (CAPOk Nothing)) + plan <- contactRequestPlan user adminContactReq Nothing `catchAllErrors` const (pure $ CPContactAddress (CAPOk Nothing)) connectWithPlan user incognito (ACCL SCMContact (CCLink adminContactReq Nothing)) plan DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm ClearContact cName -> withContactName cName $ \chatId -> APIClearChat $ ChatRef CTDirect chatId Nothing @@ -2200,12 +2234,12 @@ processChatCommand vr nm = \case -- MFAll is default for new groups unless (enableNtfs == MFAll) $ updateGroupSettings db user groupId chatSettings {enableNtfs} void (withAgent $ \a -> joinConnection a nm (aUserId user) agentConnId (enableNtfs /= MFNone) connRequest dm PQSupportOff subMode) - `catchChatError` \e -> do + `catchAllErrors` \e -> do withFastStore' $ \db -> do updateGroupMemberStatus db userId fromMember GSMemInvited updateGroupMemberStatus db userId membership GSMemInvited throwError e - updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` eToView + updateCIGroupInvitationStatus user g CIGISAccepted `catchAllErrors` eToView pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing Nothing -> throwChatError $ CEContactNotActive ct APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do @@ -2308,7 +2342,7 @@ processChatCommand vr nm = \case changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember]) changeRoleInvitedMems user gInfo memsToChange = do -- not batched, as we need to send different invitations to different connections anyway - mems_ <- forM memsToChange $ \m -> (Right <$> changeRole m) `catchChatError` (pure . Left) + mems_ <- forM memsToChange $ \m -> (Right <$> changeRole m) `catchAllErrors` (pure . Left) pure $ partitionEithers mems_ where changeRole :: GroupMember -> CM GroupMember @@ -2620,7 +2654,7 @@ processChatCommand vr nm = \case APIAcceptMemberContact contactId -> withUser $ \user -> do (g, mConn, ct, groupDirectInv) <- withFastStore $ \db -> getMemberContactInvited db vr user contactId when (groupDirectInvStartedConnection groupDirectInv) $ throwCmdError "connection already started" - connectMemberContact user g mConn ct groupDirectInv `catchChatError` \e -> do + connectMemberContact user g mConn ct groupDirectInv `catchAllErrors` \e -> do -- get updated contact, in case connection was started ct' <- withFastStore $ \db -> getContact db vr user contactId toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') @@ -3233,7 +3267,7 @@ processChatCommand vr nm = \case mergedProfile' = userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ withContactLock "updateContactPrefs" (contactId' ct) $ do - void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchChatError` eToView + void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchAllErrors` eToView lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> Group -> GroupProfile -> CM ChatResponse @@ -3411,7 +3445,7 @@ processChatCommand vr nm = \case drgRandomBytes n = asks random >>= atomically . C.randomBytes n privateGetUser :: UserId -> CM User privateGetUser userId = - tryChatError (withStore (`getUser` userId)) >>= \case + tryAllErrors (withStore (`getUser` userId)) >>= \case Left _ -> throwChatError CEUserUnknown Right user -> pure user validateUserPassword :: User -> User -> Maybe UserPwd -> CM () @@ -3452,7 +3486,7 @@ processChatCommand vr nm = \case filesInfo <- withFastStore' (`getUserFileInfo` user) deleteCIFiles user filesInfo withAgent (\a -> deleteUser a (aUserId user) delSMPQueues) - `catchChatError` \case + `catchAllErrors` \case e@(ChatErrorAgent NO_USER _) -> eToView e e -> throwError e withFastStore' (`deleteUserRecord` user) @@ -3491,11 +3525,11 @@ processChatCommand vr nm = \case -- deleted contact is returned as known, as invitation link cannot be re-used too connect anyway Nothing -> bimap inv (CPInvitationLink . ILPKnown) <$$> getContactViaShortLinkToConnect db vr user l' invitationReqAndPlan cReq sLnk_ contactSLinkData_ = do - plan <- invitationRequestPlan user cReq contactSLinkData_ `catchChatError` (pure . CPError) + plan <- invitationRequestPlan user cReq contactSLinkData_ `catchAllErrors` (pure . CPError) pure (ACCL SCMInvitation (CCLink cReq sLnk_), plan) connectPlan user (ACL SCMContact cLink) = case cLink of CLFull cReq -> do - plan <- contactOrGroupRequestPlan user cReq `catchChatError` (pure . CPError) + plan <- contactOrGroupRequestPlan user cReq `catchAllErrors` (pure . CPError) pure (ACCL SCMContact $ CCLink cReq Nothing, plan) CLShort l@(CSLContact _ ct _ _) -> do let l' = serverShortLink l @@ -3875,7 +3909,7 @@ processChatCommand vr nm = \case case contactOrGroup of CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> withFastStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr - CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchChatError` eToView + CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchAllErrors` eToView where -- we are not sending files to pending members, same as with inline files saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = @@ -4061,7 +4095,7 @@ startExpireCIThread user@User {userId} = do liftIO $ threadDelay' delay interval <- asks $ ciExpirationInterval . config forever $ do - flip catchChatError' (eToView') $ do + flip catchAllErrors' (eToView') $ do expireFlags <- asks expireCIFlags atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry lift waitChatStartedAndActivated @@ -4103,7 +4137,7 @@ agentSubscriber = do SAERcvFile -> processAgentMsgRcvFile corrId entId msg SAESndFile -> processAgentMsgSndFile corrId entId msg where - run action = action `catchChatError'` (eToView') + run action = action `catchAllErrors'` (eToView') type AgentBatchSubscribe = AgentClient -> [ConnId] -> ExceptT AgentErrorType IO (Map ConnId (Either AgentErrorType (Maybe ClientServiceId))) @@ -4209,7 +4243,7 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do netStatus = maybe NSConnected $ NSError . errorNetworkStatus errorNetworkStatus :: ChatError -> String errorNetworkStatus = \case - ChatErrorAgent (BROKER _ NETWORK) _ -> "network" + ChatErrorAgent (BROKER _ (NETWORK _)) _ -> "network" ChatErrorAgent (SMP _ SMP.AUTH) _ -> "contact deleted" e -> show e -- TODO possibly below could be replaced with less noisy events for API @@ -4251,7 +4285,7 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do pendingConnSubsToView :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId PendingContactConnection -> CM () pendingConnSubsToView rs = toViewTE . TEPendingSubSummary user . map (uncurry PendingSubStatus) . resultsFor rs withStore_ :: (DB.Connection -> User -> IO [a]) -> CM [a] - withStore_ a = withStore' (`a` user) `catchChatError` \e -> eToView e $> [] + withStore_ a = withStore' (`a` user) `catchAllErrors` \e -> eToView e $> [] filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)] filterErrors = mapMaybe (\(a, e_) -> (a,) <$> e_) resultsFor :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId a -> [(a, Maybe ChatError)] @@ -4273,40 +4307,40 @@ cleanupManager = do liftIO $ threadDelay' initialDelay stepDelay <- asks (cleanupManagerStepDelay . config) forever $ do - flip catchChatError eToView $ do + flip catchAllErrors eToView $ do lift waitChatStartedAndActivated users <- withStore' getUsers let (us, us') = partition activeUser users forM_ us $ cleanupUser interval stepDelay forM_ us' $ cleanupUser interval stepDelay - cleanupMessages `catchChatError` eToView + cleanupMessages `catchAllErrors` eToView -- TODO possibly, also cleanup async commands - cleanupProbes `catchChatError` eToView + cleanupProbes `catchAllErrors` eToView liftIO $ threadDelay' $ diffToMicroseconds interval where - runWithoutInitialDelay cleanupInterval = flip catchChatError eToView $ do + runWithoutInitialDelay cleanupInterval = flip catchAllErrors eToView $ do lift waitChatStartedAndActivated users <- withStore' getUsers let (us, us') = partition activeUser users - forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` eToView - forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` eToView + forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchAllErrors` eToView + forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchAllErrors` eToView cleanupUser cleanupInterval stepDelay user = do - cleanupTimedItems cleanupInterval user `catchChatError` eToView + cleanupTimedItems cleanupInterval user `catchAllErrors` eToView liftIO $ threadDelay' stepDelay -- TODO remove in future versions: legacy step - contacts are no longer marked as deleted - cleanupDeletedContacts user `catchChatError` eToView + cleanupDeletedContacts user `catchAllErrors` eToView liftIO $ threadDelay' stepDelay cleanupTimedItems cleanupInterval user = do ts <- liftIO getCurrentTime let startTimedThreadCutoff = addUTCTime cleanupInterval ts timedItems <- withStore' $ \db -> getTimedItems db user startTimedThreadCutoff - forM_ timedItems $ \(itemRef, deleteAt) -> startTimedItemThread user itemRef deleteAt `catchChatError` const (pure ()) + forM_ timedItems $ \(itemRef, deleteAt) -> startTimedItemThread user itemRef deleteAt `catchAllErrors` const (pure ()) cleanupDeletedContacts user = do vr <- chatVersionRange contacts <- withStore' $ \db -> getDeletedContacts db vr user forM_ contacts $ \ct -> withStore (\db -> deleteContactWithoutGroups db user ct) - `catchChatError` eToView + `catchAllErrors` eToView cleanupMessages = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(30 * nominalDay)) ts @@ -4332,7 +4366,7 @@ expireChatItems user@User {userId} globalTTL sync = do loop :: [Int64] -> (Int64 -> CM ()) -> CM () loop [] _ = pure () loop (a : as) process = continue $ do - process a `catchChatError` eToView + process a `catchAllErrors` eToView loop as process continue :: CM () -> CM () continue a = @@ -4347,7 +4381,7 @@ expireContactChatItems :: User -> VersionRangeChat -> Int64 -> ContactId -> CM ( expireContactChatItems user vr globalTTL ctId = -- reading contacts and groups inside the loop, -- to allow ttl changing while processing and to reduce memory usage - tryChatError (withStore $ \db -> getContact db vr user ctId) >>= mapM_ process + tryAllErrors (withStore $ \db -> getContact db vr user ctId) >>= mapM_ process where process ct@Contact {chatItemTTL} = withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do @@ -4358,7 +4392,7 @@ expireContactChatItems user vr globalTTL ctId = expireGroupChatItems :: User -> VersionRangeChat -> Int64 -> UTCTime -> GroupId -> CM () expireGroupChatItems user vr globalTTL createdAtCutoff groupId = - tryChatError (withStore $ \db -> getGroupInfo db vr user groupId) >>= mapM_ process + tryAllErrors (withStore $ \db -> getGroupInfo db vr user groupId) >>= mapM_ process where process gInfo@GroupInfo {chatItemTTL} = withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 413a36f567..e3885a3c9a 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -193,7 +193,7 @@ toggleNtf :: GroupMember -> Bool -> CM () toggleNtf m ntfOn = when (memberActive m) $ forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` eToView + withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchAllErrors` eToView prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> Maybe MsgScope -> MsgContent -> Map MemberName MsgMention -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup)) prepareGroupMsg db user g@GroupInfo {membership} msgScope mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of @@ -385,7 +385,7 @@ cancelFilesInProgress :: User -> [CIFileInfo] -> CM () cancelFilesInProgress user filesInfo = do let filesInfo' = filter (not . fileEnded) filesInfo (sfs, rfs) <- lift $ splitFTTypes <$> withStoreBatch (\db -> map (getFT db) filesInfo') - forM_ rfs $ \RcvFileTransfer {fileId} -> lift (closeFileHandle fileId rcvFiles) `catchChatError` \_ -> pure () + forM_ rfs $ \RcvFileTransfer {fileId} -> lift (closeFileHandle fileId rcvFiles) `catchAllErrors` \_ -> pure () lift . void . withStoreBatch' $ \db -> map (updateSndFileCancelled db) sfs lift . void . withStoreBatch' $ \db -> map (updateRcvFileCancelled db) rfs let xsfIds = mapMaybe (\(FileTransferMeta {fileId, xftpSndFile}, _) -> (,fileId) <$> xftpSndFile) sfs @@ -655,7 +655,7 @@ setFileToEncrypt ft@RcvFileTransfer {fileId} = do receiveFile' :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath -> CM ChatResponse receiveFile' user ft userApprovedRelays rcvInline_ filePath_ = do - (CRRcvFileAccepted user <$> acceptFileReceive user ft userApprovedRelays rcvInline_ filePath_) `catchChatError` processError + (CRRcvFileAccepted user <$> acceptFileReceive user ft userApprovedRelays rcvInline_ filePath_) `catchAllErrors` processError where -- TODO AChatItem in Cancelled events processError e @@ -664,7 +664,7 @@ receiveFile' user ft userApprovedRelays rcvInline_ filePath_ = do receiveFileEvt' :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath -> CM ChatEvent receiveFileEvt' user ft userApprovedRelays rcvInline_ filePath_ = do - (CEvtRcvFileAccepted user <$> acceptFileReceive user ft userApprovedRelays rcvInline_ filePath_) `catchChatError` processError + (CEvtRcvFileAccepted user <$> acceptFileReceive user ft userApprovedRelays rcvInline_ filePath_) `catchAllErrors` processError where -- TODO AChatItem in Cancelled events processError e @@ -788,7 +788,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} cleanupACIFile :: AChatItem -> CM () cleanupACIFile (AChatItem _ _ _ ChatItem {file = Just CIFile {fileSource = Just CryptoFile {filePath}}}) = do fsFilePath <- lift $ toFSFilePath filePath - removeFile fsFilePath `catchChatError` \_ -> pure () + removeFile fsFilePath `catchAllErrors` \_ -> pure () cleanupACIFile _ = pure () getKnownAgentServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> User -> CM (NonEmpty (ServerCfg p)) @@ -1089,7 +1089,7 @@ introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Ju forM_ (L.nonEmpty events) $ \events' -> sendGroupMemberMessages user conn events' groupId else forM_ shuffledIntros $ \intro -> - processIntro intro `catchChatError` eToView + processIntro intro `catchAllErrors` eToView memberIntro :: GroupMember -> ChatMsgEvent 'Json memberIntro reMember = let mInfo = memberInfo reMember @@ -1113,7 +1113,7 @@ sendHistory _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternal sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn = Just conn} = when (m `supportsVersion` batchSendVersion) $ do (errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo m 100) - (errs', events) <- partitionEithers <$> mapM (tryChatError . itemForwardEvents) items + (errs', events) <- partitionEithers <$> mapM (tryAllErrors . itemForwardEvents) items let errors = map ChatErrorStore errs <> errs' unless (null errors) $ toView $ CEvtChatErrors errors let events' = concat events @@ -1286,7 +1286,7 @@ metaBrokerTs MsgMeta {broker = (_, brokerTs)} = brokerTs createContactPQSndItem :: User -> Contact -> Connection -> PQEncryption -> CM (Contact, Connection) createContactPQSndItem user ct conn@Connection {pqSndEnabled} pqSndEnabled' = - flip catchChatError (const $ pure (ct, conn)) $ case (pqSndEnabled, pqSndEnabled') of + flip catchAllErrors (const $ pure (ct, conn)) $ case (pqSndEnabled, pqSndEnabled') of (Just b, b') | b' /= b -> createPQItem $ CISndConnEvent (SCEPqEnabled pqSndEnabled') (Nothing, PQEncOn) -> createPQItem $ CISndDirectE2EEInfo (E2EInfo $ Just pqSndEnabled') _ -> pure (ct, conn) @@ -1301,7 +1301,7 @@ createContactPQSndItem user ct conn@Connection {pqSndEnabled} pqSndEnabled' = updateContactPQRcv :: User -> Contact -> Connection -> PQEncryption -> CM (Contact, Connection) updateContactPQRcv user ct conn@Connection {connId, pqRcvEnabled} pqRcvEnabled' = - flip catchChatError (const $ pure (ct, conn)) $ case (pqRcvEnabled, pqRcvEnabled') of + flip catchAllErrors (const $ pure (ct, conn)) $ case (pqRcvEnabled, pqRcvEnabled') of (Just b, b') | b' /= b -> updatePQ $ CIRcvConnEvent (RCEPqEnabled pqRcvEnabled') (Nothing, PQEncOn) -> updatePQ $ CIRcvDirectE2EEInfo (E2EInfo $ Just pqRcvEnabled') _ -> pure (ct, conn) @@ -1539,13 +1539,13 @@ appendFileChunk ft@RcvFileTransfer {fileId, fileStatus, cryptoArgs, fileInvitati lift $ closeFileHandle fileId rcvFiles forM_ cryptoArgs $ \cfArgs -> do tmpFile <- lift getChatTempDirectory >>= liftIO . (`uniqueCombine` fileName) - tryChatError (liftError encryptErr $ encryptFile fsFilePath tmpFile cfArgs) >>= \case + tryAllErrors (liftError encryptErr $ encryptFile fsFilePath tmpFile cfArgs) >>= \case Right () -> do - removeFile fsFilePath `catchChatError` \_ -> pure () + removeFile fsFilePath `catchAllErrors` \_ -> pure () renameFile tmpFile fsFilePath Left e -> do eToView e - removeFile tmpFile `catchChatError` \_ -> pure () + removeFile tmpFile `catchAllErrors` \_ -> pure () withStore' (`removeFileCryptoArgs` fileId) where encryptErr e = fileErr $ e <> ", received file not encrypted" @@ -1569,7 +1569,7 @@ isFileActive fileId files = do cancelRcvFileTransfer :: User -> RcvFileTransfer -> CM (Maybe ConnId) cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInline} = - cancel' `catchChatError` (\e -> eToView e $> fileConnId) + cancel' `catchAllErrors` (\e -> eToView e $> fileConnId) where cancel' = do lift $ closeFileHandle fileId rcvFiles @@ -1587,13 +1587,13 @@ cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInlin cancelSndFile :: User -> FileTransferMeta -> [SndFileTransfer] -> Bool -> CM [ConnId] cancelSndFile user FileTransferMeta {fileId, xftpSndFile} fts sendCancel = do withStore' (\db -> updateFileCancelled db user fileId CIFSSndCancelled) - `catchChatError` eToView + `catchAllErrors` eToView case xftpSndFile of Nothing -> catMaybes <$> forM fts (\ft -> cancelSndFileTransfer user ft sendCancel) Just xsf -> do forM_ fts (\ft -> cancelSndFileTransfer user ft False) - lift (agentXFTPDeleteSndFileRemote user xsf fileId) `catchChatError` eToView + lift (agentXFTPDeleteSndFileRemote user xsf fileId) `catchAllErrors` eToView pure [] -- TODO v6.0 remove @@ -1601,7 +1601,7 @@ cancelSndFileTransfer :: User -> SndFileTransfer -> Bool -> CM (Maybe ConnId) cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, agentConnId = AgentConnId acId, fileStatus, fileInline} sendCancel = if fileStatus == FSCancelled || fileStatus == FSComplete then pure Nothing - else cancel' `catchChatError` (\e -> eToView e $> fileConnId) + else cancel' `catchAllErrors` (\e -> eToView e $> fileConnId) where cancel' = do withStore' $ \db -> do @@ -1661,7 +1661,7 @@ sendDirectContactMessages user ct events = do if v >= batchSend2Version then sendDirectContactMessages' user ct events else forM (L.toList events) $ \evt -> - (Right . fst <$> sendDirectContactMessage user ct evt) `catchChatError` \e -> pure (Left e) + (Right . fst <$> sendDirectContactMessage user ct evt) `catchAllErrors` \e -> pure (Left e) sendDirectContactMessages' :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM [Either ChatError SndMessage] sendDirectContactMessages' user ct events = do @@ -1856,7 +1856,7 @@ sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope sendGroupMessages user gInfo scope members events = do -- TODO [knocking] send current profile to pending member after approval? when shouldSendProfileUpdate $ - sendProfileUpdate `catchChatError` eToView + sendProfileUpdate `catchAllErrors` eToView sendGroupMessages_ user gInfo members events where User {profile = p, userMemberProfileUpdatedAt} = user @@ -2013,7 +2013,7 @@ memberSendAction gInfo events members m@GroupMember {memberRole, memberStatus} = sendGroupMemberMessage :: MsgEncodingI e => GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe Int64 -> CM () -> CM () sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent introId_ postDeliver = do msg <- createSndMessage chatMsgEvent (GroupId groupId) - messageMember msg `catchChatError` eToView + messageMember msg `catchAllErrors` eToView where messageMember :: SndMessage -> CM () messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction gInfo (chatMsgEvent :| []) [m] m) $ \case @@ -2054,7 +2054,7 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} msg <- withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery $ Just amGroupMemId) - `catchChatError` \e -> case e of + `catchAllErrors` \e -> case e of ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do vr <- chatVersionRange fm <- withStore $ \db -> getGroupMember db vr user groupId forwardedByGroupMemberId @@ -2070,7 +2070,7 @@ saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember@GroupMember {me fwdMemberId = Just $ groupMemberId' forwardingMember refAuthorId = Just $ groupMemberId' refAuthorMember withStore (\db -> createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) - `catchChatError` \e -> case e of + `catchAllErrors` \e -> case e of ChatErrorStore (SEDuplicateGroupMessage _ _ (Just authorGroupMemberId) Nothing) -> do vr <- chatVersionRange am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db vr user groupId authorGroupMemberId @@ -2213,7 +2213,7 @@ deleteAgentConnectionAsync acId = deleteAgentConnectionAsync' acId False deleteAgentConnectionAsync' :: ConnId -> Bool -> CM () deleteAgentConnectionAsync' acId waitDelivery = do - withAgent (\a -> deleteConnectionAsync a waitDelivery acId) `catchChatError` eToView + withAgent (\a -> deleteConnectionAsync a waitDelivery acId) `catchAllErrors` eToView deleteAgentConnectionsAsync :: [ConnId] -> CM () deleteAgentConnectionsAsync acIds = deleteAgentConnectionsAsync' acIds False @@ -2222,7 +2222,7 @@ deleteAgentConnectionsAsync acIds = deleteAgentConnectionsAsync' acIds False deleteAgentConnectionsAsync' :: [ConnId] -> Bool -> CM () deleteAgentConnectionsAsync' [] _ = pure () deleteAgentConnectionsAsync' acIds waitDelivery = do - withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchChatError` eToView + withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchAllErrors` eToView agentXFTPDeleteRcvFile :: RcvFileId -> FileTransferId -> CM () agentXFTPDeleteRcvFile aFileId fileId = do @@ -2271,7 +2271,7 @@ agentXFTPDeleteSndFilesRemote user sndFiles = do case privateSndFileDescr of Nothing -> partitionSndDescr xsfs (aFileId : filesWithoutDescr) filesWithDescr Just sfdText -> - tryChatError' (parseFileDescription sfdText) >>= \case + tryAllErrors' (parseFileDescription sfdText) >>= \case Left _ -> partitionSndDescr xsfs (aFileId : filesWithoutDescr) filesWithDescr Right sfd -> partitionSndDescr xsfs filesWithoutDescr ((aFileId, sfd) : filesWithDescr) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 3517f76152..9ecc09004e 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -103,7 +103,7 @@ processAgentMessage corrId connId msg = do vr <- chatVersionRange -- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here critical (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case - Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` eToView + Just user -> processAgentMessageConn vr user corrId connId msg `catchAllErrors` eToView _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) -- CRITICAL error will be shown to the user as alert with restart button in Android/desktop apps. @@ -115,7 +115,7 @@ processAgentMessage corrId connId msg = do -- Full app restart is likely to resolve database condition and the message will be received and processed again. critical :: CM a -> CM a critical a = - a `catchChatError` \case + a `catchAllErrors` \case ChatErrorStore SEDBBusyError {message} -> throwError $ ChatErrorAgent (CRITICAL True message) Nothing e -> throwError e @@ -156,7 +156,7 @@ processAgentMsgSndFile _corrId aFileId msg = do (cRef_, fileId) <- withStore (`getXFTPSndFileDBIds` AgentSndFileId aFileId) withEntityLock_ cRef_ . withFileLock "processAgentMsgSndFile" fileId $ withStore' (`getUserByASndFileId` AgentSndFileId aFileId) >>= \case - Just user -> process user fileId `catchChatError` eToView + Just user -> process user fileId `catchAllErrors` eToView _ -> do lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) throwChatError $ CENoSndFileUser $ AgentSndFileId aFileId @@ -298,7 +298,7 @@ processAgentMsgRcvFile _corrId aFileId msg = do (cRef_, fileId) <- withStore (`getXFTPRcvFileDBIds` AgentRcvFileId aFileId) withEntityLock_ cRef_ . withFileLock "processAgentMsgRcvFile" fileId $ withStore' (`getUserByARcvFileId` AgentRcvFileId aFileId) >>= \case - Just user -> process user fileId `catchChatError` eToView + Just user -> process user fileId `catchAllErrors` eToView _ -> do lift $ withAgent' (`xftpDeleteRcvFile` aFileId) throwChatError $ CENoRcvFileUser $ AgentRcvFileId aFileId @@ -472,10 +472,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withAckMessage "contact msg" agentConnId msgMeta True (Just tags) $ \eInfo -> do let MsgMeta {pqEncryption} = msgMeta (ct', conn') <- updateContactPQRcv user ct conn pqEncryption - checkIntegrityCreateItem (CDDirectRcv ct') msgMeta `catchChatError` \_ -> pure () + checkIntegrityCreateItem (CDDirectRcv ct') msgMeta `catchAllErrors` \_ -> pure () forM_ aChatMsgs $ \case Right (ACMsg _ chatMsg) -> - processEvent ct' conn' tags eInfo chatMsg `catchChatError` \e -> eToView e + processEvent ct' conn' tags eInfo chatMsg `catchAllErrors` \e -> eToView e Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "contact msg=error " <> eInfo <> " " <> tshow e @@ -537,7 +537,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn'' confId XOk XInfo profile -> do - ct' <- processContactProfileUpdate ct profile False `catchChatError` const (pure ct) + ct' <- processContactProfileUpdate ct profile False `catchAllErrors` const (pure ct) -- [incognito] send incognito profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId let p = userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True @@ -897,12 +897,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withAckMessage "group msg" agentConnId msgMeta True (Just tags) $ \eInfo -> do -- possible improvement is to choose scope based on event (some events specify scope) (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchChatError` \_ -> pure () + checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchAllErrors` \_ -> pure () (fwdScopesMsgs, shouldDelConns) <- foldM (processAChatMsg gInfo' m' tags eInfo) (M.empty, False) aChatMsgs when (isUserGrpFwdRelay gInfo') $ do unless (blockedByAdmin m) $ forM_ (M.assocs fwdScopesMsgs) $ \(groupForwardScope, fwdMsgs) -> - forwardMsgs groupForwardScope (L.reverse fwdMsgs) `catchChatError` eToView + forwardMsgs groupForwardScope (L.reverse fwdMsgs) `catchAllErrors` eToView when shouldDelConns $ deleteGroupConnections gInfo' True withRcpt <- checkSendRcpt $ rights aChatMsgs pure (withRcpt, shouldDelConns) @@ -920,7 +920,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processAChatMsg gInfo' m' tags eInfo (fwdScopeMap, shouldDelConns) = \case Right (ACMsg SJson chatMsg) -> do (cmFwdScope_, cmShouldDelConns) <- - processEvent gInfo' m' tags eInfo chatMsg `catchChatError` \e -> eToView e $> (Nothing, False) + processEvent gInfo' m' tags eInfo chatMsg `catchAllErrors` \e -> eToView e $> (Nothing, False) let fwdScopeMap' = case cmFwdScope_ of Nothing -> fwdScopeMap @@ -928,7 +928,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = shouldDelConns' = shouldDelConns || cmShouldDelConns pure (fwdScopeMap', shouldDelConns') Right (ACMsg SBinary chatMsg) -> do - void (processEvent gInfo' m' tags eInfo chatMsg) `catchChatError` \e -> eToView e + void (processEvent gInfo' m' tags eInfo chatMsg) `catchAllErrors` \e -> eToView e pure (fwdScopeMap, shouldDelConns) Left e -> do atomically $ modifyTVar' tags ("error" :) @@ -1559,7 +1559,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- 3) show screen of death to the user asking to restart eInfo <- eventInfo logInfo $ label <> ": " <> eInfo - tryChatError (action eInfo) >>= \case + tryAllErrors (action eInfo) >>= \case Right (withRcpt, shouldDelConns) -> unless shouldDelConns $ withLog (eInfo <> " ok") $ ackMsg msgMeta $ if withRcpt then Just "" else Nothing -- If showCritical is True, then these errors don't result in ACK and show user visible alert @@ -1601,7 +1601,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = e -> SndErrOther $ tshow e where brokerError srvErr = \case - NETWORK -> SndErrExpired + NETWORK _ -> SndErrExpired TIMEOUT -> SndErrExpired HOST -> srvErr SrvErrHost SMP.TRANSPORT TEVersion -> srvErr SrvErrVersion @@ -1666,7 +1666,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendProbeHashes :: [ContactOrMember] -> Probe -> Int64 -> CM () sendProbeHashes cgms probe probeId = - forM_ cgms $ \cgm -> sendProbeHash cgm `catchChatError` \_ -> pure () + forM_ cgms $ \cgm -> sendProbeHash cgm `catchAllErrors` \_ -> pure () where probeHash = ProbeHash $ C.sha256Hash (unProbe probe) sendProbeHash :: ContactOrMember -> CM () @@ -1738,7 +1738,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- in processFDMessage some paths are programmed as errors, -- for example failure on not approved relays (CEFileNotApproved). -- we catch error, so that even if processFDMessage fails, message can still be forwarded. - processFDMessage fileId aci fileDescr `catchChatError` \_ -> pure () + processFDMessage fileId aci fileDescr `catchAllErrors` \_ -> pure () pure $ Just $ toGroupForwardScope g scopeInfo else messageError "x.msg.file.descr: file of another member" $> Nothing @@ -1900,7 +1900,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = catchCINotFound :: CM a -> (SharedMsgId -> CM a) -> CM a catchCINotFound f handle = - f `catchChatError` \case + f `catchAllErrors` \case ChatErrorStore (SEChatItemSharedMsgIdNotFound sharedMsgId) -> handle sharedMsgId e -> throwError e @@ -2497,7 +2497,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = probeMatches :: [ContactOrMember] -> ContactOrMember -> CM () probeMatches [] _ = pure () probeMatches (cgm1' : cgm1s') cgm2' = do - cgm2''_ <- probeMatch cgm1' cgm2' probe `catchChatError` \_ -> pure (Just cgm2') + cgm2''_ <- probeMatch cgm1' cgm2' probe `catchAllErrors` \_ -> pure (Just cgm2') let cgm2'' = fromMaybe cgm2' cgm2''_ probeMatches cgm1s' cgm2'' @@ -3225,7 +3225,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do - checkIntegrityCreateItem (CDDirectRcv ct) msgMeta `catchChatError` \_ -> pure () + checkIntegrityCreateItem (CDDirectRcv ct) msgMeta `catchAllErrors` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus updateDirectItemStatus ct conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete @@ -3233,7 +3233,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupMsgReceived :: GroupInfo -> GroupMember -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () groupMsgReceived gInfo m conn@Connection {connId} msgMeta msgRcpts = do (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchChatError` \_ -> pure () + checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchAllErrors` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus updateGroupItemsStatus gInfo' m' conn agentMsgId (GSSRcvd msgRcptStatus) Nothing diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 7c25890bf2..47dce826ce 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -75,11 +75,11 @@ remoteFilesFolder = "simplex_v1_files" -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 4, 3, 0] +minRemoteCtrlVersion = AppVersion [6, 4, 4, 2] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 4, 3, 0] +minRemoteHostVersion = AppVersion [6, 4, 4, 2] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version @@ -175,13 +175,13 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do pure hostInfo handleConnectError :: RHKey -> SessionSeq -> CM a -> CM a handleConnectError rhKey sessSeq action = - action `catchChatError` \err -> do + action `catchAllErrors` \err -> do logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err cancelRemoteHostSession (Just (sessSeq, RHSRConnectionFailed err)) rhKey throwError err handleHostError :: SessionSeq -> TVar RHKey -> CM () -> CM () handleHostError sessSeq rhKeyVar action = - action `catchChatError` \err -> do + action `catchAllErrors` \err -> do logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just (sessSeq, RHSRCrashed err)) waitForHostSession :: Maybe RemoteHostInfo -> RHKey -> SessionSeq -> Maybe RCCtrlAddress -> TVar RHKey -> RCStepTMVar (ByteString, TLS 'TServer, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> CM () @@ -411,7 +411,7 @@ findKnownRemoteCtrl = do atomically $ takeTMVar cmdOk (RCCtrlPairing {ctrlFingerprint}, inv@(RCVerifiedInvitation RCInvitation {app})) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings - ctrlAppInfo_ <- (Just <$> parseCtrlAppInfo app) `catchChatError` const (pure Nothing) + ctrlAppInfo_ <- (Just <$> parseCtrlAppInfo app) `catchAllErrors` const (pure Nothing) rc <- withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case Nothing -> throwChatError $ CEInternalError "connecting with a stored ctrl" @@ -500,11 +500,11 @@ parseCtrlAppInfo ctrlAppInfo = do handleRemoteCommand :: (ByteString -> Int -> CM' (Either ChatError ChatResponse)) -> RemoteCrypto -> TBQueue (Either ChatError ChatEvent) -> HTTP2Request -> CM' () handleRemoteCommand execCC encryption remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do logDebug "handleRemoteCommand" - liftIO (tryRemoteError' parseRequest) >>= \case + liftIO (tryAllErrors' parseRequest) >>= \case Right (rfKN, getNext, rc) -> do chatReadVar' currentUser >>= \case Nothing -> replyError $ ChatError CENoActiveUser - Just user -> processCommand user rfKN getNext rc `catchChatError'` replyError + Just user -> processCommand user rfKN getNext rc `catchAllErrors'` replyError Left e -> reply $ RRProtocolError e where parseRequest :: ExceptT RemoteProtocolError IO (C.SbKeyNonce, GetChunk, RemoteCommand) @@ -523,7 +523,7 @@ handleRemoteCommand execCC encryption remoteOutputQ HTTP2Request {request, reqBo replyWith :: Respond replyWith rr attach = do (corrId, cmdKN, sfKN) <- atomically $ getRemoteSndKeys encryption - liftIO (tryRemoteError' . encryptEncodeHTTP2Body corrId cmdKN encryption $ J.encode rr) >>= \case + liftIO (tryAllErrors' . encryptEncodeHTTP2Body corrId cmdKN encryption $ J.encode rr) >>= \case Right resp -> liftIO . sendResponse . responseStreaming N.status200 [] $ \send flush -> do send resp attach sfKN send @@ -542,14 +542,6 @@ type Respond = RemoteResponse -> (C.SbKeyNonce -> SendChunk -> IO ()) -> CM' () liftRC :: ExceptT RemoteProtocolError IO a -> CM a liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) -tryRemoteError :: ExceptT RemoteProtocolError IO a -> ExceptT RemoteProtocolError IO (Either RemoteProtocolError a) -tryRemoteError = tryAllErrors (RPEException . tshow) -{-# INLINE tryRemoteError #-} - -tryRemoteError' :: ExceptT RemoteProtocolError IO a -> IO (Either RemoteProtocolError a) -tryRemoteError' = tryAllErrors' (RPEException . tshow) -{-# INLINE tryRemoteError' #-} - handleSend :: (ByteString -> Int -> CM' (Either ChatError ChatResponse)) -> Text -> Int -> CM' RemoteResponse handleSend execCC command retryNum = do logDebug $ "Send: " <> tshow command @@ -573,7 +565,7 @@ handleStoreFile rfKN fileName fileSize fileDigest getChunk = Just ff -> takeFileName <$$> storeFileTo ff Nothing -> storeFileTo =<< getDefaultFilesFolder storeFileTo :: FilePath -> CM' (Either RemoteProtocolError FilePath) - storeFileTo dir = liftIO . tryRemoteError' $ do + storeFileTo dir = liftIO . tryAllErrors' $ do filePath <- liftIO $ dir `uniqueCombine` fileName receiveEncryptedFile rfKN getChunk fileSize fileDigest filePath pure filePath @@ -586,7 +578,7 @@ handleGetFile User {userId} RemoteFile {userId = commandUserId, fileId, sent, fi withStore $ \db -> do cf <- getLocalCryptoFile db commandUserId fileId sent unless (cf == cf') $ throwError $ SEFileNotFound fileId - liftRC (tryRemoteError $ getFileInfo path) >>= \case + liftRC (tryAllErrors $ getFileInfo path) >>= \case Left e -> lift $ reply (RRProtocolError e) $ \_ _ -> pure () Right (fileSize, fileDigest) -> lift . withFile path ReadMode $ \h -> do @@ -658,7 +650,7 @@ stopRemoteCtrl = cancelActiveRemoteCtrl Nothing handleCtrlError :: SessionSeq -> (ChatError -> RemoteCtrlStopReason) -> Text -> CM a -> CM a handleCtrlError sseq mkReason name action = - action `catchChatError` \e -> do + action `catchAllErrors` \e -> do logError $ name <> " remote ctrl error: " <> tshow e cancelActiveRemoteCtrl $ Just (sseq, mkReason e) throwError e diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index defbe7e72c..b7af624e9e 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -28,6 +28,7 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON import Simplex.Messaging.Transport (TLS (..), TSbChainKeys (..), TransportPeer (..)) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Util (AnyError (..), tshow) import Simplex.RemoteControl.Client import Simplex.RemoteControl.Types @@ -155,6 +156,9 @@ data RemoteProtocolError | RPEException {someException :: Text} deriving (Show, Exception) +instance AnyError RemoteProtocolError where + fromSomeException = RPEException . tshow + type RemoteHostId = Int64 data RHKey = RHNew | RHId {remoteHostId :: RemoteHostId} diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index f7b07daad7..1b218df56d 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1495,13 +1495,21 @@ decreaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO decreaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do DB.execute db +#if defined(dbPostgres) [sql| UPDATE groups - SET members_require_attention = members_require_attention - 1 + SET members_require_attention = GREATEST(0, members_require_attention - 1) WHERE user_id = ? AND group_id = ? |] +#else + [sql| + UPDATE groups + SET members_require_attention = MAX(0, members_require_attention - 1) + WHERE user_id = ? AND group_id = ? + |] +#endif (userId, groupId) - pure g {membersRequireAttention = membersRequireAttention - 1} + pure g {membersRequireAttention = max 0 (membersRequireAttention - 1)} increaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO GroupInfo increaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index d1c147d43c..c152bdc4bf 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -38,6 +38,7 @@ module Simplex.Chat.Store.Messages MemberAttention (..), updateChatTsStats, setSupportChatTs, + setSupportChatMemberAttention, createNewSndChatItem, createNewRcvChatItem, createNewChatItemNoMsg, @@ -79,6 +80,7 @@ module Simplex.Chat.Store.Messages setDirectChatItemRead, setDirectChatItemsDeleteAt, updateGroupChatItemsRead, + updateSupportChatItemsRead, getGroupUnreadTimedItems, updateGroupChatItemsReadList, updateGroupScopeUnreadStats, @@ -423,14 +425,23 @@ updateChatTsStats db vr user@User {userId} chatDirection chatTs chatStats_ = cas | not nowRequires && didRequire -> do DB.execute db +#if defined(dbPostgres) [sql| UPDATE groups SET chat_ts = ?, - members_require_attention = members_require_attention - 1 + members_require_attention = GREATEST(0, members_require_attention - 1) WHERE user_id = ? AND group_id = ? |] +#else + [sql| + UPDATE groups + SET chat_ts = ?, + members_require_attention = MAX(0, members_require_attention - 1) + WHERE user_id = ? AND group_id = ? + |] +#endif (chatTs, userId, groupId) - pure $ GroupChat g {membersRequireAttention = membersRequireAttention - 1, chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) + pure $ GroupChat g {membersRequireAttention = max 0 (membersRequireAttention - 1), chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) | otherwise -> do DB.execute db @@ -496,6 +507,21 @@ setSupportChatTs :: DB.Connection -> GroupMemberId -> UTCTime -> IO () setSupportChatTs db groupMemberId chatTs = DB.execute db "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" (chatTs, groupMemberId) +setSupportChatMemberAttention :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> Int64 -> IO (GroupInfo, GroupMember) +setSupportChatMemberAttention db vr user g m memberAttention = do + m' <- updateGMAttention m + g' <- updateGroupMembersRequireAttention db user g m m' + pure (g', m') + where + updateGMAttention m@GroupMember {groupMemberId} = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ?" + (memberAttention, currentTs, groupMemberId' m) + m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + pure $ either (const m) id m_ -- Left shouldn't happen, but types require it + createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live createdAt = createNewChatItem_ db user chatDirection False createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False createdAt Nothing createdAt @@ -2010,20 +2036,46 @@ setDirectChatItemsDeleteAt db User {userId} contactId itemIds currentTs = forM i (deleteAt, userId, contactId, chatItemId) pure (chatItemId, deleteAt) -updateGroupChatItemsRead :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScope -> IO () -updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} scope = do +updateGroupChatItemsRead :: DB.Connection -> User -> GroupInfo -> IO () +updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} = do currentTs <- getCurrentTime DB.execute db [sql| UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND item_status = ? + WHERE user_id = ? AND group_id = ? + AND item_status = ? |] (CISRcvRead, currentTs, userId, groupId, CISRcvNew) - case scope of - Nothing -> pure () - Just GCSMemberSupport {groupMemberId_} -> do - let gmId = fromMaybe (groupMemberId' membership) groupMemberId_ + +updateSupportChatItemsRead :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScopeInfo -> IO (GroupInfo, GroupMember) +updateSupportChatItemsRead db vr user@User {userId} g@GroupInfo {groupId, membership} scopeInfo = do + currentTs <- getCurrentTime + case scopeInfo of + GCSIMemberSupport {groupMember_} -> do + DB.execute + db + [sql| + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? + AND item_status = ? + |] + (CISRcvRead, currentTs, userId, groupId, GCSTMemberSupport_, groupMemberId' <$> groupMember_, CISRcvNew) + case groupMember_ of + Nothing -> do + membership' <- updateGMStats membership + pure (g {membership = membership'}, membership') + Just member -> do + member' <- updateGMStats member + let didRequire = gmRequiresAttention member + nowRequires = gmRequiresAttention member' + if (not nowRequires && didRequire) + then (,member') <$> decreaseGroupMembersRequireAttention db user g + else pure (g, member') + where + updateGMStats m@GroupMember {groupMemberId} = do + currentTs <- getCurrentTime DB.execute db [sql| @@ -2033,18 +2085,34 @@ updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} scope support_chat_items_mentions = 0 WHERE group_member_id = ? |] - (Only gmId) + (Only groupMemberId) + m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + pure $ either (const m) id m_ -- Left shouldn't happen, but types require it -getGroupUnreadTimedItems :: DB.Connection -> User -> GroupId -> IO [(ChatItemId, Int)] -getGroupUnreadTimedItems db User {userId} groupId = - DB.query - db - [sql| - SELECT chat_item_id, timed_ttl - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL - |] - (userId, groupId, CISRcvNew) +getGroupUnreadTimedItems :: DB.Connection -> User -> GroupId -> Maybe GroupChatScope -> IO [(ChatItemId, Int)] +getGroupUnreadTimedItems db User {userId} groupId scope = + case scope of + Nothing -> + DB.query + db + [sql| + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + |] + (userId, groupId, CISRcvNew) + Just GCSMemberSupport {groupMemberId_} -> + DB.query + db + [sql| + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? + AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + |] + (userId, groupId, GCSTMemberSupport_, groupMemberId_, CISRcvNew) updateGroupChatItemsReadList :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo) updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId} scopeInfo_ itemIds = do @@ -2110,14 +2178,25 @@ updateGroupScopeUnreadStats db vr user g@GroupInfo {membership} scopeInfo (unrea currentTs <- getCurrentTime DB.execute db +#if defined(dbPostgres) [sql| UPDATE group_members - SET support_chat_items_unread = support_chat_items_unread - ?, - support_chat_items_member_attention = support_chat_items_member_attention - ?, - support_chat_items_mentions = support_chat_items_mentions - ?, + SET support_chat_items_unread = GREATEST(0, support_chat_items_unread - ?), + support_chat_items_member_attention = GREATEST(0, support_chat_items_member_attention - ?), + support_chat_items_mentions = GREATEST(0, support_chat_items_mentions - ?), updated_at = ? WHERE group_member_id = ? |] +#else + [sql| + UPDATE group_members + SET support_chat_items_unread = MAX(0, support_chat_items_unread - ?), + support_chat_items_member_attention = MAX(0, support_chat_items_member_attention - ?), + support_chat_items_mentions = MAX(0, support_chat_items_mentions - ?), + updated_at = ? + WHERE group_member_id = ? + |] +#endif (unread, unanswered, mentions, currentTs, groupMemberId) m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId pure $ either (const m) id m_ -- Left shouldn't happen, but types require it diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 7eacc2337b..5a76567db0 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -25,7 +25,7 @@ SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE groups SET chat_ts = ?, - members_require_attention = members_require_attention + 1 + members_require_attention = MAX(0, members_require_attention - 1) WHERE user_id = ? AND group_id = ? Plan: @@ -34,7 +34,7 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE groups SET chat_ts = ?, - members_require_attention = members_require_attention - 1 + members_require_attention = members_require_attention + 1 WHERE user_id = ? AND group_id = ? Plan: @@ -1189,6 +1189,25 @@ Plan: SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=?) USE TEMP B-TREE FOR ORDER BY +Query: + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? + AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) + +Query: + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) + Query: SELECT chat_item_moderation_id, moderator_member_id, created_by_msg_id, moderated_at FROM chat_item_moderations @@ -1431,6 +1450,15 @@ SEARCH r USING INDEX idx_received_probes_user_id (user_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? + AND item_status = ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) + Query: UPDATE connections SET via_contact_uri = NULL, via_contact_uri_hash = NULL, xcontact_id = NULL WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( @@ -1482,9 +1510,9 @@ SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE group_members - SET support_chat_items_unread = support_chat_items_unread - ?, - support_chat_items_member_attention = support_chat_items_member_attention - ?, - support_chat_items_mentions = support_chat_items_mentions - ?, + SET support_chat_items_unread = MAX(0, support_chat_items_unread - ?), + support_chat_items_member_attention = MAX(0, support_chat_items_member_attention - ?), + support_chat_items_mentions = MAX(0, support_chat_items_mentions - ?), updated_at = ? WHERE group_member_id = ? @@ -4267,14 +4295,6 @@ Query: Plan: SEARCH chat_items USING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) -Query: - SELECT chat_item_id, timed_ttl - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL - -Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) - Query: SELECT group_snd_item_status, COUNT(1) FROM group_snd_item_statuses @@ -4361,7 +4381,8 @@ SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND item_status = ? + WHERE user_id = ? AND group_id = ? + AND item_status = ? Plan: SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) @@ -4623,7 +4644,7 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE groups - SET members_require_attention = members_require_attention + 1 + SET members_require_attention = MAX(0, members_require_attention - 1) WHERE user_id = ? AND group_id = ? Plan: @@ -4631,7 +4652,7 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE groups - SET members_require_attention = members_require_attention - 1 + SET members_require_attention = members_require_attention + 1 WHERE user_id = ? AND group_id = ? Plan: @@ -6277,6 +6298,14 @@ Query: UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_memb Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET support_chat_items_member_attention=100 WHERE group_member_id=? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -6317,6 +6346,10 @@ Query: UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE groups SET members_require_attention=1 WHERE group_id=? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET request_shared_msg_id = ? WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 1caf034f2a..745f1b4b9a 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -46,6 +46,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..)) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (SubscriptionMode (..)) +import Simplex.Messaging.Util (AnyError (..)) import Simplex.Messaging.Version import UnliftIO.STM #if defined(dbPostgres) @@ -149,6 +150,10 @@ data StoreError | SEInvalidMention deriving (Show, Exception) +instance AnyError StoreError where + fromSomeException = SEInternalError . show + {-# INLINE fromSomeException #-} + $(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) insertedRowId :: DB.Connection -> IO Int64 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6fef6cc204..89bc58171c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -68,7 +68,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, BlockingInfo (..), BlockingReason (..), ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, BlockingInfo (..), BlockingReason (..), NetworkError (..), ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) @@ -231,6 +231,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m CRMemberAccepted u g m -> ttyUser u $ viewMemberAccepted g m + CRMemberSupportChatRead u g m -> ttyUser u $ viewSupportChatRead g m CRMemberSupportChatDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " support chat deleted"] CRMembersRoleUser u g members r' -> ttyUser u $ viewMemberRoleUserChanged g members r' CRMembersBlockedForAllUser u g members blocked -> ttyUser u $ viewMembersBlockedForAllUser g members blocked @@ -1229,6 +1230,11 @@ viewMemberAccepted g m@GroupMember {memberStatus} = case memberStatus of GSMemPendingReview -> [ttyGroup' g <> ": " <> ttyMember m <> " accepted and pending review (will introduce moderators)"] _ -> [ttyGroup' g <> ": " <> ttyMember m <> " accepted"] +viewSupportChatRead :: GroupInfo -> GroupMember -> [StyledString] +viewSupportChatRead g@GroupInfo {membership = GroupMember {groupMemberId = membershipId}} m + | groupMemberId' m == membershipId = [ttyGroup' g <> ": support chat read"] + | otherwise = [ttyGroup' g <> ": " <> ttyMember m <> " support chat read"] + viewMemberAcceptedByOther :: GroupInfo -> GroupMember -> GroupMember -> [StyledString] viewMemberAcceptedByOther g acceptingMember m@GroupMember {memberCategory, memberStatus} = case memberCategory of GCUserMember -> case memberStatus of @@ -1324,8 +1330,12 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt | otherwise = [] viewMemberSupportChats :: GroupInfo -> [GroupMember] -> [StyledString] -viewMemberSupportChats GroupInfo {membership} ms = support <> map groupMember ms +viewMemberSupportChats GroupInfo {membership = membership@GroupMember {memberRole = membershipRole}, membersRequireAttention} ms = + memsAttention <> support <> map groupMember ms where + memsAttention + | membershipRole >= GRModerator = ["members require attention: " <> sShow membersRequireAttention] + | otherwise = [] support = case supportChat membership of Just sc -> ["support: " <> chatStats sc] Nothing -> [] @@ -1505,11 +1515,11 @@ viewServerTestResult (AProtoServerWithAuth p _) = \case result <> [pName <> " server requires authorization to create queues, check password" | testStep == TSCreateQueue && (case testError of SMP _ SMP.AUTH -> True; _ -> False)] <> [pName <> " server requires authorization to upload files, check password" | testStep == TSCreateFile && (case testError of XFTP _ XFTP.AUTH -> True; _ -> False)] - <> ["Possibly, certificate fingerprint in " <> pName <> " server address is incorrect" | testStep == TSConnect && brokerErr] + <> ["Certificate fingerprint in " <> pName <> " server address does not match server certificate" | testStep == TSConnect && unknownCA] where result = [pName <> " server test failed at " <> plain (drop 2 $ show testStep) <> ", error: " <> sShow testError] - brokerErr = case testError of - BROKER _ NETWORK -> True + unknownCA = case testError of + BROKER _ (NETWORK NEUnknownCAError) -> True _ -> False _ -> [pName <> " server test passed"] where @@ -2536,7 +2546,7 @@ viewChatError isCmd logLevel testView = \case reasonStr = case reason of BRSpam -> "spam" BRContent -> "content violates conditions of use" - BROKER _ NETWORK | not isCmd -> [] + BROKER _ (NETWORK _) | not isCmd -> [] BROKER _ TIMEOUT | not 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] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index cac90936a8..5d64514277 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1161,8 +1161,8 @@ testTestSMPServerConnection = alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001" alice <## "SMP server test passed" alice ##> "/smp test smp://LcJU@localhost:7001" - alice <## "SMP server test failed at Connect, error: BROKER {brokerAddress = \"smp://LcJU@localhost:7001\", brokerErr = NETWORK}" - alice <## "Possibly, certificate fingerprint in SMP server address is incorrect" + alice <## "SMP server test failed at Connect, error: BROKER {brokerAddress = \"smp://LcJU@localhost:7001\", brokerErr = NETWORK {networkError = NEUnknownCAError}}" + alice <## "Certificate fingerprint in SMP server address does not match server certificate" testGetSetXFTPServers :: HasCallStack => TestParams -> IO () testGetSetXFTPServers = @@ -1203,8 +1203,8 @@ testTestXFTPServer = alice ##> "/xftp test xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002" alice <## "XFTP server test passed" alice ##> "/xftp test xftp://LcJU@localhost:7002" - alice <## "XFTP server test failed at Connect, error: BROKER {brokerAddress = \"xftp://LcJU@localhost:7002\", brokerErr = NETWORK}" - alice <## "Possibly, certificate fingerprint in XFTP server address is incorrect" + alice <## "XFTP server test failed at Connect, error: BROKER {brokerAddress = \"xftp://LcJU@localhost:7002\", brokerErr = NETWORK {networkError = NEUnknownCAError}}" + alice <## "Certificate fingerprint in XFTP server address does not match server certificate" testOperators :: HasCallStack => TestParams -> IO () testOperators = diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 365bd16f71..7f8761c1e1 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -18,6 +18,7 @@ import Control.Concurrent.Async (concurrently_) import Control.Monad (forM_, void, when) import Data.Bifunctor (second) import qualified Data.ByteString.Char8 as B +import Data.Int (Int64) import Data.List (intercalate, isInfixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T @@ -219,6 +220,7 @@ chatGroupTests = do 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 + it "should correct member attention stat for support chat on opening it" testScopedSupportUnreadStatsCorrectOnOpen testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -7432,8 +7434,10 @@ testScopedSupportManyModerators = cath #$> ("/_get chat #1(_support:3) count=100", chat, []) alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" dan ##> "/member support chats #team" + dan <## "members require attention: 0" dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 0, require attention: 0, mentions: 0" @@ -7888,8 +7892,10 @@ testScopedSupportUnreadStatsOnRead = dan <# "#team (support: bob) alice> 3" alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" dan ##> "/member support chats #team" + dan <## "members require attention: 0" dan <## "bob (Bob) (id 3): unread: 1, require attention: 0, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 1, require attention: 0, mentions: 0" @@ -7899,8 +7905,10 @@ testScopedSupportUnreadStatsOnRead = [alice, dan] *<# "#team (support: bob) bob> 4" alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 1, require attention: 0, mentions: 0" @@ -7913,9 +7921,11 @@ testScopedSupportUnreadStatsOnRead = bob <# "#team (support) dan> 5" alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 2, require attention: 0, mentions: 0" -- In test "answering" doesn't reset unanswered, but in UI items would be marked read on opening chat dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 2, require attention: 0, mentions: 0" @@ -7928,8 +7938,10 @@ testScopedSupportUnreadStatsOnRead = bob <# "#team (support) dan> @alice 6" alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 3, require attention: 0, mentions: 1" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 3, require attention: 0, mentions: 0" @@ -7944,8 +7956,10 @@ testScopedSupportUnreadStatsOnRead = dan <# "#team (support: bob) bob> @alice 7" alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 4, require attention: 1, mentions: 2" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 3, require attention: 2, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 3, require attention: 0, mentions: 0" @@ -7958,8 +7972,10 @@ testScopedSupportUnreadStatsOnRead = dan <# "#team (support: bob) bob!> @dan 8" alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 5, require attention: 2, mentions: 2" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 4, require attention: 3, mentions: 1" bob ##> "/member support chats #team" bob <## "support: unread: 3, require attention: 0, mentions: 0" @@ -7967,11 +7983,13 @@ testScopedSupportUnreadStatsOnRead = alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByDanItemId, id, "items read for chat") alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 4, require attention: 2, mentions: 1" alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByBobItemId, id, "items read for chat") alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 3, require attention: 1, mentions: 0" threadDelay 1000000 @@ -7982,23 +8000,30 @@ testScopedSupportUnreadStatsOnRead = bob <# "#team (support) dan!> @bob 9" alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 4, require attention: 0, mentions: 0" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 4, require attention: 3, mentions: 1" bob ##> "/member support chats #team" bob <## "support: unread: 4, require attention: 0, mentions: 1" - alice #$> ("/_read chat #1(_support:2)", id, "ok") + alice ##> "/_read chat #1(_support:2)" + alice <## "#team: bob support chat read" alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" - dan #$> ("/_read chat #1(_support:3)", id, "ok") + dan ##> "/_read chat #1(_support:3)" + dan <## "#team: bob support chat read" dan ##> "/member support chats #team" + dan <## "members require attention: 0" dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" - bob #$> ("/_read chat #1(_support)", id, "ok") + bob ##> "/_read chat #1(_support)" + bob <## "#team: support chat read" bob ##> "/member support chats #team" bob <## "support: unread: 0, require attention: 0, mentions: 0" @@ -8029,12 +8054,90 @@ testScopedSupportUnreadStatsOnDelete = msgIdBob <- lastItemId bob alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0" bob #$> ("/_delete item #1(_support) " <> msgIdBob <> " broadcast", id, "message deleted") alice <# "#team (support: bob) bob> [deleted] 1" alice ##> "/member support chats #team" + alice <## "members require attention: 0" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + where + opts = + testOpts + { markRead = False + } + +testScopedSupportUnreadStatsCorrectOnOpen :: HasCallStack => TestParams -> IO () +testScopedSupportUnreadStatsCorrectOnOpen = + testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do + createGroup2 "team" alice bob + + bob #> "#team (support) 1" + alice <# "#team (support: bob) bob> 1" + + bob #> "#team (support) 2" + alice <# "#team (support: bob) bob> 2" + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 2, require attention: 2, mentions: 0" + + alice ##> "/_read chat #1(_support:2)" + alice <## "#team: bob support chat read" + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + + bob #> "#team (support) 3" + alice <# "#team (support: bob) bob> 3" + + bob #> "#team (support) 4" + alice <# "#team (support: bob) bob> 4" + + bob #> "#team (support) 5" + alice <# "#team (support: bob) bob> 5" + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 3, mentions: 0" + + -- opening chat should correct group_members.support_chat_items_member_attention value if it got out of sync + void $ withCCTransaction alice $ \db -> + DB.execute db "UPDATE group_members SET support_chat_items_member_attention=100 WHERE group_member_id=?" (Only (2 :: Int64)) + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 100, mentions: 0" + + alice #$> ("/_get chat #1(_support:2) count=100", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 3, mentions: 0" + + alice ##> "/_read chat #1(_support:2)" + alice <## "#team: bob support chat read" + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + + -- opening chat should also correct groups.members_require_attention value if corrected member no longer requires attention + void $ withCCTransaction alice $ \db -> do + DB.execute db "UPDATE group_members SET support_chat_items_member_attention=100 WHERE group_member_id=?" (Only (2 :: Int64)) + DB.execute db "UPDATE groups SET members_require_attention=1 WHERE group_id=?" (Only (1 :: Int64)) + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 100, mentions: 0" + + alice #$> ("/_get chat #1(_support:2) count=100", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" where opts =