mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-24 15:15:35 +00:00
Merge branch 'master' into master-android
This commit is contained in:
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +160,8 @@ enum SEChatCommand: ChatCmdProtocol {
|
||||
} else {
|
||||
"(_support)"
|
||||
}
|
||||
case .reports:
|
||||
"(reports, prohibited)" // can't use surrogate Reports scope
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>";
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
+18
-5
@@ -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")
|
||||
|
||||
+5
-10
@@ -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)
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
+9
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+25
-2
@@ -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,
|
||||
|
||||
+25
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -162,6 +162,7 @@ undocumentedResponses =
|
||||
"CRGroupUserChanged",
|
||||
"CRItemsReadForChat",
|
||||
"CRJoinedGroupMember",
|
||||
"CRMemberSupportChatRead",
|
||||
"CRMemberSupportChatDeleted",
|
||||
"CRMemberSupportChats",
|
||||
"CRNetworkConfig",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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,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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 #-}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=?)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user