Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2025-09-07 15:27:33 +01:00
47 changed files with 1030 additions and 280 deletions
+5
View File
@@ -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)")
+16 -12
View File
@@ -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
}
}
+23 -3
View File
@@ -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)
+49 -11
View File
@@ -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<MergedItem> = 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(
@@ -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 {
@@ -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)
}
@@ -107,6 +107,8 @@ struct GroupMentionsView: View {
} else {
return member.memberRole >= .moderator
}
case .reports:
return false
}
case .msgContentTagContext:
return false
@@ -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 {
@@ -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()
})
}
}
}
+2
View File
@@ -160,6 +160,8 @@ enum SEChatCommand: ChatCmdProtocol {
} else {
"(_support)"
}
case .reports:
"(reports, prohibited)" // can't use surrogate Reports scope
}
}
}
+8 -8
View File
@@ -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 = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
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 = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-IWC91cESq2l3JFgl07ryw1.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.4.2-IWC91cESq2l3JFgl07ryw1.a"; sourceTree = "<group>"; };
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 = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.4.2-7qDqJsgFG1qLUMejyoXxtN.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
+27 -6
View File
@@ -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
@@ -2343,7 +2343,7 @@ data class GroupMember (
}
fun canChangeRoleTo(groupInfo: GroupInfo): List<GroupMemberRole>? =
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
@@ -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<GroupInfo, GroupMember>? {
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<Long>): 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")
@@ -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<Long
if (deleted != null) {
withContext(Dispatchers.Main) {
for (di in deleted) {
val toChatItem = di.toChatItem?.chatItem
if (toChatItem != null) {
chatModel.chatsContext.upsertChatItem(chatRh, chatInfo, toChatItem)
} else {
chatModel.chatsContext.removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem)
}
val deletedItem = di.deletedChatItem.chatItem
if (deletedItem.isActiveReport) {
chatModel.chatsContext.decreaseGroupReportsCounter(chatRh, chatInfo.id)
@@ -126,7 +126,7 @@ fun ModalData.GroupChatInfoView(
}
ModalManager.end.showModalCloseable(true) { closeCurrent ->
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()
}
@@ -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<Chat, Contact>? {
@@ -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,
)
}
}
@@ -51,7 +51,10 @@ private fun MemberSupportChatView(
@Composable
fun MemberSupportChatAppBar(
chatsCtx: ChatModel.ChatsContext,
rhId: Long?,
chat: Chat,
scopeMember_: GroupMember?,
scrollToItemId: MutableState<Long?>,
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,
@@ -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)
}
}
}
}
}
@@ -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
}
@@ -167,6 +167,7 @@
<string name="error_adding_members">Error adding member(s)</string>
<string name="error_joining_group">Error joining group</string>
<string name="error_accepting_member">Error accepting member</string>
<string name="error_marking_member_support_chat_read">Error marking chat with member as read</string>
<string name="error_deleting_member_support_chat">Error deleting chat with member</string>
<string name="cannot_receive_file">Cannot receive file</string>
<string name="sender_cancelled_file_transfer">Sender cancelled file transfer.</string>
@@ -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
+30
View File
@@ -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
+1
View File
@@ -162,6 +162,7 @@ undocumentedResponses =
"CRGroupUserChanged",
"CRItemsReadForChat",
"CRJoinedGroupMember",
"CRMemberSupportChatRead",
"CRMemberSupportChatDeleted",
"CRMemberSupportChats",
"CRNetworkConfig",
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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
+197
View File
@@ -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.
@@ -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<br>(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<br>(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<br>(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<br>(plus some visual indication)
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

@@ -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
@@ -38,6 +38,27 @@
</description>
<releases>
<release version="6.4.4" date="2025-08-28">
<url type="details">https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html</url>
<description>
<p>New in v6.4.4:</p>
<ul>
<li>reduced battery usage.</li>
<li>fixes.</li>
</ul>
<p>New in v6.4-6.4.3.1:</p>
<ul>
<li>new UX to connect.</li>
<li>review new group members.</li>
<li>chat with group admins.</li>
<li>new UI languages: Catalan, Indonesian, Romanian and Vietnamese.</li>
<li>Linux app builds for aarch64 CPUs</li>
<li>UI support for bot commands.</li>
<li>support markdown hyperlinks, such as [click here](https://example.com).</li>
<li>option to remove tracking parameters from the links.</li>
</ul>
</description>
</release>
<release version="6.4.3.1" date="2025-08-10">
<url type="details">https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html</url>
<description>
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+8 -39
View File
@@ -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 #-}
+83 -49
View File
@@ -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
+24 -24
View File
@@ -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)
+19 -19
View File
@@ -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
+11 -19
View File
@@ -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
+4
View File
@@ -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}
+10 -2
View File
@@ -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
+102 -23
View File
@@ -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
@@ -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=?)
+5
View File
@@ -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
+16 -6
View File
@@ -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]
+4 -4
View File
@@ -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 =
+106 -3
View File
@@ -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 =